feat: delegated admin sections for second-admin roles (#123)

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>
pull/3582/head
Tomas Dvorak 2 weeks ago
parent c6bf098b87
commit bb53ee5c4c

@ -1281,6 +1281,18 @@
"isAdminDes": "When enabled, users under this group will have admin permissions.", "isAdminDes": "When enabled, users under this group will have admin permissions.",
"loginIPWhitelist": "Login IP whitelist", "loginIPWhitelist": "Login IP whitelist",
"loginIPWhitelistDes": "Restrict sign-in to these IPs or CIDR ranges, one per line. Leave empty to allow all.", "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", "share": "Share",
"allowCreateShareLink": "Create share link", "allowCreateShareLink": "Create share link",
"allowCreateShareLinkDes": "If disabled, users cannot create sharing links.", "allowCreateShareLinkDes": "If disabled, users cannot create sharing links.",

@ -1281,6 +1281,18 @@
"isAdminDes": "开启后,用户组下的用户将拥有管理员权限。", "isAdminDes": "开启后,用户组下的用户将拥有管理员权限。",
"loginIPWhitelist": "登录 IP 白名单", "loginIPWhitelist": "登录 IP 白名单",
"loginIPWhitelistDes": "仅允许以下 IP 或 CIDR 网段登录,每行一条。留空则不限制。", "loginIPWhitelistDes": "仅允许以下 IP 或 CIDR 网段登录,每行一条。留空则不限制。",
"delegatedAdmin": "委派管理区块",
"delegatedAdminDes": "仅授予所选管理后台区块的访问权限。该组用户可打开管理面板,但仅启用的区块可用,其余管理 API 请求会被拒绝。",
"sectionUsers": "用户",
"sectionGroups": "用户组",
"sectionFiles": "文件与存储块",
"sectionShares": "分享",
"sectionStorage": "存储策略与节点",
"sectionQueue": "任务队列",
"sectionSettings": "站点设置与 OAuth",
"sectionPayment": "支付",
"sectionEvents": "事件",
"sectionReports": "滥用举报",
"share": "分享", "share": "分享",
"allowCreateShareLink": "创建分享链接", "allowCreateShareLink": "创建分享链接",
"allowCreateShareLinkDes": "关闭后,用户无法创建分享链接。", "allowCreateShareLinkDes": "关闭后,用户无法创建分享链接。",

@ -1,3 +1,5 @@
import Boolset from "../util/boolset";
/** /**
* UserLoginService 管理用户登录的服务 * UserLoginService 管理用户登录的服务
*/ */
@ -95,6 +97,39 @@ export const GroupPermission = {
advance_delete: 12, advance_delete: 12,
unique_direct_link: 17, unique_direct_link: 17,
webdav_read_only: 18, 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 { export interface UserSettings {

@ -57,6 +57,16 @@ const BasicInfoSection = () => {
[setGroup], [setGroup],
); );
const onSectionChange = useCallback(
(bit: number) => (e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
...p,
permissions: new Boolset(p.permissions).set(bit, e.target.checked).toString(),
}));
},
[setGroup],
);
const onWhitelistChange = useCallback( const onWhitelistChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
const list = e.target.value const list = e.target.value
@ -119,6 +129,33 @@ const BasicInfoSection = () => {
<NoMarginHelperText>{t("group.isAdminDes")}</NoMarginHelperText> <NoMarginHelperText>{t("group.isAdminDes")}</NoMarginHelperText>
</FormControl> </FormControl>
</SettingForm> </SettingForm>
{!permission.enabled(GroupPermission.is_admin) && (
<SettingForm title={t("group.delegatedAdmin")} lgWidth={5}>
<FormControl fullWidth>
<NoMarginHelperText>{t("group.delegatedAdminDes")}</NoMarginHelperText>
{(
[
[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]) => (
<FormControlLabel
key={bit}
control={<Switch checked={permission.enabled(bit)} onChange={onSectionChange(bit)} />}
label={t(label)}
/>
))}
</FormControl>
</SettingForm>
)}
<SettingForm title={t("group.loginIPWhitelist")} lgWidth={5}> <SettingForm title={t("group.loginIPWhitelist")} lgWidth={5}>
<FormControl fullWidth> <FormControl fullWidth>
<DenseFilledTextField <DenseFilledTextField

@ -4,7 +4,7 @@ import SvgIcon from "@mui/material/SvgIcon/SvgIcon";
import { memo, useMemo } from "react"; import { memo, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { GroupPermission } from "../../../api/user.ts"; import { GroupPermission, isAnyAdmin } from "../../../api/user.ts";
import { useAppSelector } from "../../../redux/hooks.ts"; import { useAppSelector } from "../../../redux/hooks.ts";
import SessionManager from "../../../session"; import SessionManager from "../../../session";
import { GroupBS } from "../../../session/utils.ts"; import { GroupBS } from "../../../session/utils.ts";
@ -52,6 +52,9 @@ export interface NavigationItem {
icon?: ((props: SvgIconProps) => JSX.Element)[] | (typeof SvgIcon)[]; icon?: ((props: SvgIconProps) => JSX.Element)[] | (typeof SvgIcon)[];
iconifyName?: string; iconifyName?: string;
path: 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[]; let NavigationItems: NavigationItem[];
@ -137,80 +140,100 @@ AdminNavigationItems = [
label: "dashboard:nav.settings", label: "dashboard:nav.settings",
icon: [Setting, SettingsOutlined], icon: [Setting, SettingsOutlined],
path: "/admin/settings", path: "/admin/settings",
permission: GroupPermission.admin_settings,
}, },
{ {
label: "dashboard:nav.fileSystem", label: "dashboard:nav.fileSystem",
icon: [CubeTreeFilled, CubeTree], icon: [CubeTreeFilled, CubeTree],
path: "/admin/filesystem", path: "/admin/filesystem",
permission: GroupPermission.admin_settings,
}, },
{ {
label: "dashboard:nav.storagePolicy", label: "dashboard:nav.storagePolicy",
icon: [Storage, StorageOutlined], icon: [Storage, StorageOutlined],
path: "/admin/policy", path: "/admin/policy",
permission: GroupPermission.admin_storage,
}, },
{ {
label: "dashboard:nav.nodes", label: "dashboard:nav.nodes",
icon: [ServerFilled, Server], icon: [ServerFilled, Server],
path: "/admin/node", path: "/admin/node",
permission: GroupPermission.admin_storage,
}, },
{ {
label: "dashboard:nav.groups", label: "dashboard:nav.groups",
icon: [PeopleFilled, People], icon: [PeopleFilled, People],
path: "/admin/group", path: "/admin/group",
permission: GroupPermission.admin_groups,
}, },
{ {
label: "dashboard:nav.users", label: "dashboard:nav.users",
icon: [Person, PersonOutlined], icon: [Person, PersonOutlined],
path: "/admin/user", path: "/admin/user",
permission: GroupPermission.admin_users,
}, },
{ {
label: "dashboard:nav.files", label: "dashboard:nav.files",
icon: [Folder, FolderOutlined], icon: [Folder, FolderOutlined],
path: "/admin/file", path: "/admin/file",
permission: GroupPermission.admin_files,
}, },
{ {
label: "dashboard:nav.entities", label: "dashboard:nav.entities",
icon: [BoxMultipleFilled, BoxMultiple], icon: [BoxMultipleFilled, BoxMultiple],
path: "/admin/blob", path: "/admin/blob",
permission: GroupPermission.admin_files,
}, },
{ {
label: "dashboard:nav.shares", label: "dashboard:nav.shares",
icon: [ShareAndroid, ShareOutlined], icon: [ShareAndroid, ShareOutlined],
path: "/admin/share", path: "/admin/share",
permission: GroupPermission.admin_shares,
}, },
{ {
label: "dashboard:nav.tasks", label: "dashboard:nav.tasks",
icon: [CubeSyncFilled, CubeSync], icon: [CubeSyncFilled, CubeSync],
path: "/admin/task", path: "/admin/task",
permission: GroupPermission.admin_queue,
}, },
{ {
label: "dashboard:vas.orders", label: "dashboard:vas.orders",
icon: [PaymentFilled, Payment], icon: [PaymentFilled, Payment],
path: "/admin/payment", path: "/admin/payment",
permission: GroupPermission.admin_payment,
}, },
{ {
label: "dashboard:nav.events", label: "dashboard:nav.events",
icon: [SendLoggingFilled, SendLogging], icon: [SendLoggingFilled, SendLogging],
path: "/admin/event", path: "/admin/event",
permission: GroupPermission.admin_events,
}, },
{ {
label: "dashboard:nav.abuseReport", label: "dashboard:nav.abuseReport",
icon: [Warning, WarningOutlined], icon: [Warning, WarningOutlined],
path: "/admin/abuse", path: "/admin/abuse",
permission: GroupPermission.admin_reports,
}, },
{ {
label: "dashboard:nav.oauthClients", label: "dashboard:nav.oauthClients",
icon: [ShieldLockFilled, ShieldLock], icon: [ShieldLockFilled, ShieldLock],
path: "/admin/oauth", path: "/admin/oauth",
permission: GroupPermission.admin_settings,
}, },
]; ];
export const AdminPageNavigation = memo(() => { 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 ( return (
<> <>
<SideNavItemComponent key={AdminNavigationItems[0].label} item={AdminNavigationItems[0]} /> <SideNavItemComponent key={visibleItems[0].label} item={visibleItems[0]} />
<Box> <Box>
{AdminNavigationItems.slice(1).map((item) => ( {visibleItems.slice(1).map((item) => (
<SideNavItemComponent key={item.label} item={item} /> <SideNavItemComponent key={item.label} item={item} />
))} ))}
</Box> </Box>
@ -229,7 +252,7 @@ const PageNavigation = () => {
const appPromotionEnabled = useAppSelector((state) => state.siteConfig.basic.config.app_promotion); const appPromotionEnabled = useAppSelector((state) => state.siteConfig.basic.config.app_promotion);
const user = SessionManager.currentLoginOrNull(); const user = SessionManager.currentLoginOrNull();
const isAdmin = useMemo(() => { const isAdmin = useMemo(() => {
return GroupBS(user?.user).enabled(GroupPermission.is_admin); return isAnyAdmin(GroupBS(user?.user));
}, [user?.user?.group?.permission]); }, [user?.user?.group?.permission]);
const remoteDownloadEnabled = useMemo(() => { const remoteDownloadEnabled = useMemo(() => {
return GroupBS(user?.user).enabled(GroupPermission.remote_download); return GroupBS(user?.user).enabled(GroupPermission.remote_download);

@ -17,7 +17,7 @@ import { bindTrigger, usePopupState } from "material-ui-popup-state/hooks";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; 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 { useAppDispatch } from "../../../redux/hooks.ts";
import { signout } from "../../../redux/thunks/session.ts"; import { signout } from "../../../redux/thunks/session.ts";
import SessionManager, { Session } from "../../../session"; import SessionManager, { Session } from "../../../session";
@ -47,7 +47,7 @@ const UserPopover = ({ open, onClose, ...rest }: PopoverProps) => {
} }
const isAdmin = useMemo(() => { const isAdmin = useMemo(() => {
return GroupBS(user).enabled(GroupPermission.is_admin); return isAnyAdmin(GroupBS(user));
}, [user.group?.permission]); }, [user.group?.permission]);
const signWithHint = (email: string) => { const signWithHint = (email: string) => {

@ -290,8 +290,44 @@ const (
// read operations — write methods (PUT, MKCOL, DELETE, COPY, MOVE, LOCK, // read operations — write methods (PUT, MKCOL, DELETE, COPY, MOVE, LOCK,
// PROPPATCH) are rejected even if the group's WebDAV access is enabled. // PROPPATCH) are rejected even if the group's WebDAV access is enabled.
GroupPermissionWebDAVReadOnly 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 ( const (
NodeCapabilityNone NodeCapability = iota NodeCapabilityNone NodeCapability = iota
NodeCapabilityCreateArchive NodeCapabilityCreateArchive

@ -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")
}
})
}

@ -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. // 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. // 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"). // Write scopes implicitly include Read scopes for the same resource (e.g., "File.Write" includes "File.Read").

@ -924,7 +924,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
auth.Use(middleware.LoginRequired()) auth.Use(middleware.LoginRequired())
{ {
// 管理 // 管理
admin := auth.Group("admin", middleware.IsAdmin()) admin := auth.Group("admin", middleware.IsAdminOrDelegated())
admin.Use(middleware.RequiredScopes(types.ScopeAdminRead)) admin.Use(middleware.RequiredScopes(types.ScopeAdminRead))
{ {
admin.GET("summary", admin.GET("summary",
@ -932,7 +932,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
controllers.AdminSummary, controllers.AdminSummary,
) )
settings := admin.Group("settings") settings := admin.Group("settings", middleware.AdminSection(types.GroupPermissionAdminSettings))
{ {
// Get settings // Get settings
settings.POST("", 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("", 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", tool.GET("wopi",
middleware.RequiredScopes(types.ScopeAdminWrite), 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) queue.GET("metrics", controllers.AdminGetQueueMetrics)
// List tasks // 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("", 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("", node.POST("",
controllers.FromJSON[adminsvc.AdminListService](adminsvc.AdminListServiceParamsCtx{}), 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 // List OAuth clients
oauthClient.POST("", 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("", 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("", 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 // List blobs
entity.POST("", 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 // List shares
share.POST("", share.POST("",

@ -8,6 +8,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/ent" "github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/inventory" "github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types" "github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer" "github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/gin-gonic/gin" "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) 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) group, err := groupClient.Upsert(c, s.Group)
if err != nil { if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to update group", err) 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) 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) group, err := groupClient.Upsert(c, s.Group)
if err != nil { if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to create group", err) 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} service := &SingleGroupService{ID: group.ID}
return service.Get(c) 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
}

@ -10,6 +10,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/ent/user" "github.com/cloudreve/Cloudreve/v4/ent/user"
"github.com/cloudreve/Cloudreve/v4/inventory" "github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types" "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/filemanager/manager"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid" "github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer" "github.com/cloudreve/Cloudreve/v4/pkg/serializer"
@ -168,6 +169,58 @@ type adminUserEmailValidation struct {
Email string `binding:"required,email"` 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 { func (s *UpsertUserService) validateEmail() error {
if s.User == nil { if s.User == nil {
return serializer.NewError(serializer.CodeParamErr, "Email format error", 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) newUser, err := userClient.Upsert(ctx, s.User, s.Password, s.TwoFA)
if err != nil { if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to update user", err) 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) 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) user, err := userClient.Upsert(c, s.User, s.Password, s.TwoFA)
if err != nil { if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to create user", err) 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() fileClient := dep.FileClient()
current := inventory.UserFromContext(c) current := inventory.UserFromContext(c)
fullAdmin := actorIsFullAdmin(c)
groupCtx := context.WithValue(c, inventory.LoadUserGroup{}, true)
ae := serializer.NewAggregateError() ae := serializer.NewAggregateError()
for _, id := range s.IDs { for _, id := range s.IDs {
if current.ID == id || id == 1 { if current.ID == id || id == 1 {
@ -258,6 +321,19 @@ func (s *BatchUserService) Delete(c *gin.Context) error {
continue 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) fc, tx, ctx, err := inventory.WithTx(c, fileClient)
if err != nil { if err != nil {
ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeDBError, "Failed to start transaction", err)) 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) dep := dependency.FromContext(c)
userClient := dep.UserClient() userClient := dep.UserClient()
current := inventory.UserFromContext(c) 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. // The caller and the reserved initial admin cannot be modified in bulk.
ae := serializer.NewAggregateError() ae := serializer.NewAggregateError()
groupCtx := context.WithValue(c, inventory.LoadUserGroup{}, true)
ids := lo.Filter(s.IDs, func(id int, _ int) bool { ids := lo.Filter(s.IDs, func(id int, _ int) bool {
if id == current.ID || id == 1 { if id == current.ID || id == 1 {
ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeInvalidActionOnDefaultUser, "Cannot modify this user in bulk", nil)) ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeInvalidActionOnDefaultUser, "Cannot modify this user in bulk", nil))
return false 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 return true
}) })

Loading…
Cancel
Save