From bb53ee5c4c536358dd30780975dccfa313d2b724 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Sat, 19 Sep 2026 02:06:51 +0200 Subject: [PATCH] feat: delegated admin sections for second-admin roles (#123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds per-section admin permission bits (users, groups, files, shares, storage, queue, settings, payment, events, reports). A group carrying any of these bits — without the full is_admin bit — becomes a delegated administrator that can open the admin panel but only access its granted sections. Backend enforcement, not just tab hiding: - IsAdminOrDelegated gates the /admin route tree; AdminSection middleware gates each admin subgroup (user/group/file/entity/share/policy/node/ queue/settings/oauthClient/tool). - Escalation guards: delegated admins cannot grant or modify admin-capable permission bits on groups, cannot create admin-capable groups, cannot assign users to admin groups, and cannot edit, demote, or delete members of admin-capable groups (single + batch paths). - Existing full admins keep unrestricted access; group 1 stays admin. Frontend: - Admin nav filters items by granted section bits; the dashboard entry appears for delegated admins too. - Group editor shows per-section switches when "Admin group" is off. Middleware tests cover full/delegated/non-admin and matching vs non-matching section access. Closes #123 (fork). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- frontend/public/locales/en-US/dashboard.json | 12 +++ frontend/public/locales/zh-CN/dashboard.json | 12 +++ frontend/src/api/user.ts | 35 ++++++ .../Group/EditGroup/BasicInfoSection.tsx | 37 +++++++ .../component/Frame/NavBar/PageNavigation.tsx | 31 +++++- .../src/component/Frame/NavBar/UserAction.tsx | 4 +- inventory/types/types.go | 36 +++++++ middleware/adminsection_test.go | 102 ++++++++++++++++++ middleware/auth.go | 52 +++++++++ routers/router.go | 24 ++--- service/admin/group.go | 43 ++++++++ service/admin/user.go | 101 +++++++++++++++++ 12 files changed, 471 insertions(+), 18 deletions(-) create mode 100644 middleware/adminsection_test.go diff --git a/frontend/public/locales/en-US/dashboard.json b/frontend/public/locales/en-US/dashboard.json index 7fbdf0cd..1e740dd4 100644 --- a/frontend/public/locales/en-US/dashboard.json +++ b/frontend/public/locales/en-US/dashboard.json @@ -1281,6 +1281,18 @@ "isAdminDes": "When enabled, users under this group will have admin permissions.", "loginIPWhitelist": "Login IP whitelist", "loginIPWhitelistDes": "Restrict sign-in to these IPs or CIDR ranges, one per line. Leave empty to allow all.", + "delegatedAdmin": "Delegated admin sections", + "delegatedAdminDes": "Grant access to selected dashboard sections only. Users in this group can open the admin panel, but only the enabled sections work — all other admin API calls are rejected.", + "sectionUsers": "Users", + "sectionGroups": "Groups", + "sectionFiles": "Files & blobs", + "sectionShares": "Shares", + "sectionStorage": "Storage policies & nodes", + "sectionQueue": "Task queue", + "sectionSettings": "Site settings & OAuth", + "sectionPayment": "Payments", + "sectionEvents": "Events", + "sectionReports": "Abuse reports", "share": "Share", "allowCreateShareLink": "Create share link", "allowCreateShareLinkDes": "If disabled, users cannot create sharing links.", diff --git a/frontend/public/locales/zh-CN/dashboard.json b/frontend/public/locales/zh-CN/dashboard.json index 52cf6563..c8a1bf2a 100644 --- a/frontend/public/locales/zh-CN/dashboard.json +++ b/frontend/public/locales/zh-CN/dashboard.json @@ -1281,6 +1281,18 @@ "isAdminDes": "开启后,用户组下的用户将拥有管理员权限。", "loginIPWhitelist": "登录 IP 白名单", "loginIPWhitelistDes": "仅允许以下 IP 或 CIDR 网段登录,每行一条。留空则不限制。", + "delegatedAdmin": "委派管理区块", + "delegatedAdminDes": "仅授予所选管理后台区块的访问权限。该组用户可打开管理面板,但仅启用的区块可用,其余管理 API 请求会被拒绝。", + "sectionUsers": "用户", + "sectionGroups": "用户组", + "sectionFiles": "文件与存储块", + "sectionShares": "分享", + "sectionStorage": "存储策略与节点", + "sectionQueue": "任务队列", + "sectionSettings": "站点设置与 OAuth", + "sectionPayment": "支付", + "sectionEvents": "事件", + "sectionReports": "滥用举报", "share": "分享", "allowCreateShareLink": "创建分享链接", "allowCreateShareLinkDes": "关闭后,用户无法创建分享链接。", diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index 021211eb..d22b5de0 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -1,3 +1,5 @@ +import Boolset from "../util/boolset"; + /** * UserLoginService 管理用户登录的服务 */ @@ -95,6 +97,39 @@ export const GroupPermission = { advance_delete: 12, unique_direct_link: 17, webdav_read_only: 18, + admin_users: 19, + admin_groups: 20, + admin_files: 21, + admin_shares: 22, + admin_storage: 23, + admin_queue: 24, + admin_settings: 25, + admin_payment: 26, + admin_events: 27, + admin_reports: 28, +}; + +// Delegated admin section bits — is_admin implies all of them. +export const DelegatedAdminPermissions = [ + GroupPermission.admin_users, + GroupPermission.admin_groups, + GroupPermission.admin_files, + GroupPermission.admin_shares, + GroupPermission.admin_storage, + GroupPermission.admin_queue, + GroupPermission.admin_settings, + GroupPermission.admin_payment, + GroupPermission.admin_events, + GroupPermission.admin_reports, +]; + +// isAnyAdmin reports whether the permission set grants full or delegated admin +// access to at least one section. +export const isAnyAdmin = (permission: Boolset): boolean => { + return ( + permission.enabled(GroupPermission.is_admin) || + DelegatedAdminPermissions.some((p) => permission.enabled(p)) + ); }; export interface UserSettings { diff --git a/frontend/src/component/Admin/Group/EditGroup/BasicInfoSection.tsx b/frontend/src/component/Admin/Group/EditGroup/BasicInfoSection.tsx index 35691c63..8ef1a904 100644 --- a/frontend/src/component/Admin/Group/EditGroup/BasicInfoSection.tsx +++ b/frontend/src/component/Admin/Group/EditGroup/BasicInfoSection.tsx @@ -57,6 +57,16 @@ const BasicInfoSection = () => { [setGroup], ); + const onSectionChange = useCallback( + (bit: number) => (e: React.ChangeEvent) => { + setGroup((p: GroupEnt) => ({ + ...p, + permissions: new Boolset(p.permissions).set(bit, e.target.checked).toString(), + })); + }, + [setGroup], + ); + const onWhitelistChange = useCallback( (e: React.ChangeEvent) => { const list = e.target.value @@ -119,6 +129,33 @@ const BasicInfoSection = () => { {t("group.isAdminDes")} + {!permission.enabled(GroupPermission.is_admin) && ( + + + {t("group.delegatedAdminDes")} + {( + [ + [GroupPermission.admin_users, "group.sectionUsers"], + [GroupPermission.admin_groups, "group.sectionGroups"], + [GroupPermission.admin_files, "group.sectionFiles"], + [GroupPermission.admin_shares, "group.sectionShares"], + [GroupPermission.admin_storage, "group.sectionStorage"], + [GroupPermission.admin_queue, "group.sectionQueue"], + [GroupPermission.admin_settings, "group.sectionSettings"], + [GroupPermission.admin_payment, "group.sectionPayment"], + [GroupPermission.admin_events, "group.sectionEvents"], + [GroupPermission.admin_reports, "group.sectionReports"], + ] as [number, string][] + ).map(([bit, label]) => ( + } + label={t(label)} + /> + ))} + + + )} JSX.Element)[] | (typeof SvgIcon)[]; iconifyName?: string; path: string; + // Delegated admin section bit required to see this item. Full admins + // (is_admin) always pass; undefined means any admin. + permission?: number; } let NavigationItems: NavigationItem[]; @@ -137,80 +140,100 @@ AdminNavigationItems = [ label: "dashboard:nav.settings", icon: [Setting, SettingsOutlined], path: "/admin/settings", + permission: GroupPermission.admin_settings, }, { label: "dashboard:nav.fileSystem", icon: [CubeTreeFilled, CubeTree], path: "/admin/filesystem", + permission: GroupPermission.admin_settings, }, { label: "dashboard:nav.storagePolicy", icon: [Storage, StorageOutlined], path: "/admin/policy", + permission: GroupPermission.admin_storage, }, { label: "dashboard:nav.nodes", icon: [ServerFilled, Server], path: "/admin/node", + permission: GroupPermission.admin_storage, }, { label: "dashboard:nav.groups", icon: [PeopleFilled, People], path: "/admin/group", + permission: GroupPermission.admin_groups, }, { label: "dashboard:nav.users", icon: [Person, PersonOutlined], path: "/admin/user", + permission: GroupPermission.admin_users, }, { label: "dashboard:nav.files", icon: [Folder, FolderOutlined], path: "/admin/file", + permission: GroupPermission.admin_files, }, { label: "dashboard:nav.entities", icon: [BoxMultipleFilled, BoxMultiple], path: "/admin/blob", + permission: GroupPermission.admin_files, }, { label: "dashboard:nav.shares", icon: [ShareAndroid, ShareOutlined], path: "/admin/share", + permission: GroupPermission.admin_shares, }, { label: "dashboard:nav.tasks", icon: [CubeSyncFilled, CubeSync], path: "/admin/task", + permission: GroupPermission.admin_queue, }, { label: "dashboard:vas.orders", icon: [PaymentFilled, Payment], path: "/admin/payment", + permission: GroupPermission.admin_payment, }, { label: "dashboard:nav.events", icon: [SendLoggingFilled, SendLogging], path: "/admin/event", + permission: GroupPermission.admin_events, }, { label: "dashboard:nav.abuseReport", icon: [Warning, WarningOutlined], path: "/admin/abuse", + permission: GroupPermission.admin_reports, }, { label: "dashboard:nav.oauthClients", icon: [ShieldLockFilled, ShieldLock], path: "/admin/oauth", + permission: GroupPermission.admin_settings, }, ]; export const AdminPageNavigation = memo(() => { + const user = SessionManager.currentLoginOrNull(); + const permission = useMemo(() => GroupBS(user?.user), [user?.user?.group?.permission]); + const isFullAdmin = permission.enabled(GroupPermission.is_admin); + const visibleItems = AdminNavigationItems.filter( + (item) => item.permission === undefined || isFullAdmin || permission.enabled(item.permission), + ); return ( <> - + - {AdminNavigationItems.slice(1).map((item) => ( + {visibleItems.slice(1).map((item) => ( ))} @@ -229,7 +252,7 @@ const PageNavigation = () => { const appPromotionEnabled = useAppSelector((state) => state.siteConfig.basic.config.app_promotion); const user = SessionManager.currentLoginOrNull(); const isAdmin = useMemo(() => { - return GroupBS(user?.user).enabled(GroupPermission.is_admin); + return isAnyAdmin(GroupBS(user?.user)); }, [user?.user?.group?.permission]); const remoteDownloadEnabled = useMemo(() => { return GroupBS(user?.user).enabled(GroupPermission.remote_download); diff --git a/frontend/src/component/Frame/NavBar/UserAction.tsx b/frontend/src/component/Frame/NavBar/UserAction.tsx index ab79422c..fb00fa66 100644 --- a/frontend/src/component/Frame/NavBar/UserAction.tsx +++ b/frontend/src/component/Frame/NavBar/UserAction.tsx @@ -17,7 +17,7 @@ import { bindTrigger, usePopupState } from "material-ui-popup-state/hooks"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { GroupPermission } from "../../../api/user.ts"; +import { isAnyAdmin } from "../../../api/user.ts"; import { useAppDispatch } from "../../../redux/hooks.ts"; import { signout } from "../../../redux/thunks/session.ts"; import SessionManager, { Session } from "../../../session"; @@ -47,7 +47,7 @@ const UserPopover = ({ open, onClose, ...rest }: PopoverProps) => { } const isAdmin = useMemo(() => { - return GroupBS(user).enabled(GroupPermission.is_admin); + return isAnyAdmin(GroupBS(user)); }, [user.group?.permission]); const signWithHint = (email: string) => { diff --git a/inventory/types/types.go b/inventory/types/types.go index a470ba47..bdc06de4 100644 --- a/inventory/types/types.go +++ b/inventory/types/types.go @@ -290,8 +290,44 @@ const ( // read operations — write methods (PUT, MKCOL, DELETE, COPY, MOVE, LOCK, // PROPPATCH) are rejected even if the group's WebDAV access is enabled. GroupPermissionWebDAVReadOnly + // Delegated admin section permissions. A group with any of these bits — + // but without GroupPermissionIsAdmin — is a delegated administrator that + // can only access the corresponding admin sections. GroupPermissionIsAdmin + // implies all sections. + GroupPermissionAdminUsers + GroupPermissionAdminGroups + GroupPermissionAdminFiles + GroupPermissionAdminShares + GroupPermissionAdminStorage + GroupPermissionAdminQueue + GroupPermissionAdminSettings + GroupPermissionAdminPayment + GroupPermissionAdminEvents + GroupPermissionAdminReports ) +// DelegatedAdminPermissions lists every per-section admin permission bit. +// GroupPermissionIsAdmin implies all of them. +func DelegatedAdminPermissions() []GroupPermission { + return []GroupPermission{ + GroupPermissionAdminUsers, + GroupPermissionAdminGroups, + GroupPermissionAdminFiles, + GroupPermissionAdminShares, + GroupPermissionAdminStorage, + GroupPermissionAdminQueue, + GroupPermissionAdminSettings, + GroupPermissionAdminPayment, + GroupPermissionAdminEvents, + GroupPermissionAdminReports, + } +} + +// AdminPermissionBits returns all admin-capable bits, including IsAdmin. +func AdminPermissionBits() []GroupPermission { + return append([]GroupPermission{GroupPermissionIsAdmin}, DelegatedAdminPermissions()...) +} + const ( NodeCapabilityNone NodeCapability = iota NodeCapabilityCreateArchive diff --git a/middleware/adminsection_test.go b/middleware/adminsection_test.go new file mode 100644 index 00000000..0604a163 --- /dev/null +++ b/middleware/adminsection_test.go @@ -0,0 +1,102 @@ +package middleware + +import ( + "net/http/httptest" + "testing" + + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/inventory" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/boolset" + "github.com/cloudreve/Cloudreve/v4/pkg/util" + "github.com/gin-gonic/gin" +) + +func perms(bits ...types.GroupPermission) *boolset.BooleanSet { + b := &boolset.BooleanSet{} + for _, p := range bits { + boolset.Set(int(p), true, b) + } + return b +} + +func newAdminRequest(t *testing.T, permissions *boolset.BooleanSet) *gin.Context { + t.Helper() + w := httptest.NewRecorder() + c := gin.CreateTestContextOnly(w, testEngine) + c.Request = httptest.NewRequest("GET", "/api/v4/admin/summary", nil) + u := &ent.User{ + ID: 2, + Edges: ent.UserEdges{ + Group: &ent.Group{Permissions: permissions}, + }, + } + util.WithValue(c, inventory.UserCtx{}, u) + return c +} + +func TestIsAdminOrDelegated(t *testing.T) { + gin.SetMode(gin.TestMode) + handler := IsAdminOrDelegated() + + t.Run("full admin passes", func(t *testing.T) { + c := newAdminRequest(t, perms(types.GroupPermissionIsAdmin)) + handler(c) + if c.IsAborted() { + t.Fatal("full admin was rejected") + } + }) + + t.Run("delegated admin passes", func(t *testing.T) { + c := newAdminRequest(t, perms(types.GroupPermissionAdminUsers)) + handler(c) + if c.IsAborted() { + t.Fatal("delegated admin was rejected") + } + }) + + t.Run("regular user rejected", func(t *testing.T) { + c := newAdminRequest(t, perms(types.GroupPermissionShare)) + handler(c) + if !c.IsAborted() { + t.Fatal("regular user was not rejected") + } + }) +} + +func TestAdminSection(t *testing.T) { + gin.SetMode(gin.TestMode) + handler := AdminSection(types.GroupPermissionAdminUsers, types.GroupPermissionAdminGroups) + + t.Run("full admin passes any section", func(t *testing.T) { + c := newAdminRequest(t, perms(types.GroupPermissionIsAdmin)) + handler(c) + if c.IsAborted() { + t.Fatal("full admin was rejected") + } + }) + + t.Run("matching delegated section passes", func(t *testing.T) { + c := newAdminRequest(t, perms(types.GroupPermissionAdminUsers)) + handler(c) + if c.IsAborted() { + t.Fatal("delegated admin with matching section was rejected") + } + }) + + t.Run("non-matching delegated section rejected", func(t *testing.T) { + c := newAdminRequest(t, perms(types.GroupPermissionAdminFiles)) + handler(c) + if !c.IsAborted() { + t.Fatal("delegated admin without matching section was not rejected") + } + }) + + t.Run("regular user rejected", func(t *testing.T) { + c := newAdminRequest(t, perms()) + handler(c) + if !c.IsAborted() { + t.Fatal("regular user was not rejected") + } + }) +} diff --git a/middleware/auth.go b/middleware/auth.go index 3b179ea7..941da161 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -335,6 +335,58 @@ func IsAdmin() gin.HandlerFunc { } } +// IsAdminOrDelegated allows full admins and delegated administrators (groups +// carrying at least one per-section admin permission bit). +func IsAdminOrDelegated() gin.HandlerFunc { + return func(c *gin.Context) { + user := inventory.UserFromContext(c) + permissions := user.Edges.Group.Permissions + if !permissions.Enabled(int(types.GroupPermissionIsAdmin)) { + delegated := false + for _, p := range types.DelegatedAdminPermissions() { + if permissions.Enabled(int(p)) { + delegated = true + break + } + } + + if !delegated { + c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeNoPermissionErr, "", nil)) + c.Abort() + return + } + } + + c.Next() + } +} + +// AdminSection requires the full admin permission or at least one of the +// given delegated admin section permissions. +func AdminSection(sections ...types.GroupPermission) gin.HandlerFunc { + return func(c *gin.Context) { + user := inventory.UserFromContext(c) + permissions := user.Edges.Group.Permissions + if !permissions.Enabled(int(types.GroupPermissionIsAdmin)) { + allowed := false + for _, p := range sections { + if permissions.Enabled(int(p)) { + allowed = true + break + } + } + + if !allowed { + c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeNoPermissionErr, "", nil)) + c.Abort() + return + } + } + + c.Next() + } +} + // RequiredScopes checks if the JWT token has the required scopes. // If the token has scopes (hasScopes is true), it verifies the token has all required scopes. // Write scopes implicitly include Read scopes for the same resource (e.g., "File.Write" includes "File.Read"). diff --git a/routers/router.go b/routers/router.go index 8aef1436..cb919e57 100644 --- a/routers/router.go +++ b/routers/router.go @@ -924,7 +924,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { auth.Use(middleware.LoginRequired()) { // 管理 - admin := auth.Group("admin", middleware.IsAdmin()) + admin := auth.Group("admin", middleware.IsAdminOrDelegated()) admin.Use(middleware.RequiredScopes(types.ScopeAdminRead)) { admin.GET("summary", @@ -932,7 +932,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { controllers.AdminSummary, ) - settings := admin.Group("settings") + settings := admin.Group("settings", middleware.AdminSection(types.GroupPermissionAdminSettings)) { // Get settings settings.POST("", @@ -948,7 +948,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { } // 用户组管理 - group := admin.Group("group") + group := admin.Group("group", middleware.AdminSection(types.GroupPermissionAdminGroups)) { // 列出用户组 group.POST("", @@ -980,7 +980,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { ) } - tool := admin.Group("tool") + tool := admin.Group("tool", middleware.AdminSection(types.GroupPermissionAdminSettings)) { tool.GET("wopi", middleware.RequiredScopes(types.ScopeAdminWrite), @@ -1002,7 +1002,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { ) } - queue := admin.Group("queue") + queue := admin.Group("queue", middleware.AdminSection(types.GroupPermissionAdminQueue)) { queue.GET("metrics", controllers.AdminGetQueueMetrics) // List tasks @@ -1034,7 +1034,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { } // 存储策略管理 - policy := admin.Group("policy") + policy := admin.Group("policy", middleware.AdminSection(types.GroupPermissionAdminStorage)) { // 列出存储策略 policy.POST("", @@ -1100,7 +1100,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { ) } - node := admin.Group("node") + node := admin.Group("node", middleware.AdminSection(types.GroupPermissionAdminStorage)) { node.POST("", controllers.FromJSON[adminsvc.AdminListService](adminsvc.AdminListServiceParamsCtx{}), @@ -1137,7 +1137,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { ) } - oauthClient := admin.Group("oauthClient") + oauthClient := admin.Group("oauthClient", middleware.AdminSection(types.GroupPermissionAdminSettings)) { // List OAuth clients oauthClient.POST("", @@ -1175,7 +1175,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { ) } - user := admin.Group("user") + user := admin.Group("user", middleware.AdminSection(types.GroupPermissionAdminUsers)) { // 列出用户 user.POST("", @@ -1221,7 +1221,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { ) } - file := admin.Group("file") + file := admin.Group("file", middleware.AdminSection(types.GroupPermissionAdminFiles)) { // 列出文件 file.POST("", @@ -1252,7 +1252,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { ) } - entity := admin.Group("entity") + entity := admin.Group("entity", middleware.AdminSection(types.GroupPermissionAdminFiles)) { // List blobs entity.POST("", @@ -1277,7 +1277,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { ) } - share := admin.Group("share") + share := admin.Group("share", middleware.AdminSection(types.GroupPermissionAdminShares)) { // List shares share.POST("", diff --git a/service/admin/group.go b/service/admin/group.go index e2cddf84..36b98e74 100644 --- a/service/admin/group.go +++ b/service/admin/group.go @@ -8,6 +8,7 @@ import ( "github.com/cloudreve/Cloudreve/v4/ent" "github.com/cloudreve/Cloudreve/v4/inventory" "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/boolset" "github.com/cloudreve/Cloudreve/v4/pkg/serializer" "github.com/gin-gonic/gin" ) @@ -165,6 +166,12 @@ func (s *UpsertGroupService) Update(c *gin.Context) (*GetGroupResponse, error) { return nil, serializer.NewError(serializer.CodeParamErr, "Initial admin group have to be admin", nil) } + // Delegated admins cannot modify admin-capable permission bits — the + // target group's existing admin bits are preserved. + if err := s.maskAdminPermissions(c, groupClient); err != nil { + return nil, err + } + group, err := groupClient.Upsert(c, s.Group) if err != nil { return nil, serializer.NewError(serializer.CodeDBError, "Failed to update group", err) @@ -182,6 +189,11 @@ func (s *UpsertGroupService) Create(c *gin.Context) (*GetGroupResponse, error) { return nil, serializer.NewError(serializer.CodeParamErr, "ID must be 0", nil) } + // Delegated admins cannot create groups carrying admin-capable bits. + if err := s.maskAdminPermissions(c, groupClient); err != nil { + return nil, err + } + group, err := groupClient.Upsert(c, s.Group) if err != nil { return nil, serializer.NewError(serializer.CodeDBError, "Failed to create group", err) @@ -190,3 +202,34 @@ func (s *UpsertGroupService) Create(c *gin.Context) (*GetGroupResponse, error) { service := &SingleGroupService{ID: group.ID} return service.Get(c) } + +// maskAdminPermissions strips admin-capable permission bits from the payload +// when the acting user is not a full administrator, preventing delegated +// admins from escalating privileges. For updates, the target group's existing +// admin bits are preserved. +func (s *UpsertGroupService) maskAdminPermissions(c *gin.Context, groupClient inventory.GroupClient) error { + actor := inventory.UserFromContext(c) + if actor.Edges.Group != nil && actor.Edges.Group.Permissions != nil && + actor.Edges.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin)) { + return nil + } + + if s.Group.Permissions == nil { + s.Group.Permissions = &boolset.BooleanSet{} + } + if s.Group.ID > 0 { + existing, err := groupClient.GetByID(c, s.Group.ID) + if err != nil { + return serializer.NewError(serializer.CodeDBError, "Failed to get group", err) + } + for _, p := range types.AdminPermissionBits() { + boolset.Set(int(p), existing.Permissions != nil && existing.Permissions.Enabled(int(p)), s.Group.Permissions) + } + } else { + for _, p := range types.AdminPermissionBits() { + boolset.Set(int(p), false, s.Group.Permissions) + } + } + + return nil +} diff --git a/service/admin/user.go b/service/admin/user.go index cc946242..319eb2d6 100644 --- a/service/admin/user.go +++ b/service/admin/user.go @@ -10,6 +10,7 @@ import ( "github.com/cloudreve/Cloudreve/v4/ent/user" "github.com/cloudreve/Cloudreve/v4/inventory" "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/boolset" "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager" "github.com/cloudreve/Cloudreve/v4/pkg/hashid" "github.com/cloudreve/Cloudreve/v4/pkg/serializer" @@ -168,6 +169,58 @@ type adminUserEmailValidation struct { Email string `binding:"required,email"` } +// groupIsAdminCapable reports whether a permission set grants full or +// delegated admin access. +func groupIsAdminCapable(permissions *boolset.BooleanSet) bool { + if permissions == nil { + return false + } + for _, p := range types.AdminPermissionBits() { + if permissions.Enabled(int(p)) { + return true + } + } + return false +} + +// actorIsFullAdmin reports whether the acting user belongs to a group with the +// full admin permission. +func actorIsFullAdmin(c *gin.Context) bool { + actor := inventory.UserFromContext(c) + return actor.Edges.Group != nil && actor.Edges.Group.Permissions != nil && + actor.Edges.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin)) +} + +// guardAdminGroupChange rejects delegated admins that try to move users into +// or out of admin-capable groups. newGroupID == 0 keeps the current group. +func guardAdminGroupChange(c *gin.Context, dep dependency.Dep, existing *ent.User, newGroupID int) error { + if actorIsFullAdmin(c) { + return nil + } + + if existing != nil && existing.Edges.Group != nil && groupIsAdminCapable(existing.Edges.Group.Permissions) { + return serializer.NewError(serializer.CodeNoPermissionErr, "Cannot modify an administrator", nil) + } + + targetGroupID := newGroupID + if targetGroupID == 0 && existing != nil { + targetGroupID = existing.GroupUsers + } + if targetGroupID == 0 { + return nil + } + + target, err := dep.GroupClient().GetByID(c, targetGroupID) + if err != nil { + return serializer.NewError(serializer.CodeDBError, "Failed to get group", err) + } + if groupIsAdminCapable(target.Permissions) { + return serializer.NewError(serializer.CodeNoPermissionErr, "Cannot assign an administrator group", nil) + } + + return nil +} + func (s *UpsertUserService) validateEmail() error { if s.User == nil { return serializer.NewError(serializer.CodeParamErr, "Email format error", nil) @@ -203,6 +256,10 @@ func (s *UpsertUserService) Update(c *gin.Context) (*GetUserResponse, error) { } + if err := guardAdminGroupChange(c, dep, existing, s.User.GroupUsers); err != nil { + return nil, err + } + newUser, err := userClient.Upsert(ctx, s.User, s.Password, s.TwoFA) if err != nil { return nil, serializer.NewError(serializer.CodeDBError, "Failed to update user", err) @@ -228,6 +285,10 @@ func (s *UpsertUserService) Create(c *gin.Context) (*GetUserResponse, error) { return nil, serializer.NewError(serializer.CodeParamErr, "ID must be 0", nil) } + if err := guardAdminGroupChange(c, dep, nil, s.User.GroupUsers); err != nil { + return nil, err + } + user, err := userClient.Upsert(c, s.User, s.Password, s.TwoFA) if err != nil { return nil, serializer.NewError(serializer.CodeDBError, "Failed to create user", err) @@ -251,6 +312,8 @@ func (s *BatchUserService) Delete(c *gin.Context) error { fileClient := dep.FileClient() current := inventory.UserFromContext(c) + fullAdmin := actorIsFullAdmin(c) + groupCtx := context.WithValue(c, inventory.LoadUserGroup{}, true) ae := serializer.NewAggregateError() for _, id := range s.IDs { if current.ID == id || id == 1 { @@ -258,6 +321,19 @@ func (s *BatchUserService) Delete(c *gin.Context) error { continue } + // Delegated admins cannot delete members of admin-capable groups. + if !fullAdmin { + target, err := userClient.GetByID(groupCtx, id) + if err != nil { + ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeDBError, "Failed to get user", err)) + continue + } + if target.Edges.Group != nil && groupIsAdminCapable(target.Edges.Group.Permissions) { + ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeNoPermissionErr, "Cannot delete an administrator", nil)) + continue + } + } + fc, tx, ctx, err := inventory.WithTx(c, fileClient) if err != nil { ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeDBError, "Failed to start transaction", err)) @@ -310,14 +386,39 @@ func (s *BatchUserUpdateService) Update(c *gin.Context) error { dep := dependency.FromContext(c) userClient := dep.UserClient() current := inventory.UserFromContext(c) + fullAdmin := actorIsFullAdmin(c) + + // Delegated admins cannot move users into an admin-capable group. + if !fullAdmin && s.GroupID > 0 { + target, err := dep.GroupClient().GetByID(c, s.GroupID) + if err != nil { + return serializer.NewError(serializer.CodeDBError, "Failed to get group", err) + } + if groupIsAdminCapable(target.Permissions) { + return serializer.NewError(serializer.CodeNoPermissionErr, "Cannot assign an administrator group", nil) + } + } // The caller and the reserved initial admin cannot be modified in bulk. ae := serializer.NewAggregateError() + groupCtx := context.WithValue(c, inventory.LoadUserGroup{}, true) ids := lo.Filter(s.IDs, func(id int, _ int) bool { if id == current.ID || id == 1 { ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeInvalidActionOnDefaultUser, "Cannot modify this user in bulk", nil)) return false } + // Delegated admins cannot modify members of admin-capable groups. + if !fullAdmin { + target, err := userClient.GetByID(groupCtx, id) + if err != nil { + ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeDBError, "Failed to get user", err)) + return false + } + if target.Edges.Group != nil && groupIsAdminCapable(target.Edges.Group.Permissions) { + ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeNoPermissionErr, "Cannot modify an administrator", nil)) + return false + } + } return true })