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
parent
c6bf098b87
commit
bb53ee5c4c
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Reference in new issue