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