Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/parameters.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2520,6 +2520,20 @@ type: stringSlice
default: []
components: ["cache", "director", "origin", "registry"]
---
name: Server.AdminGroups
description: |+
A string slice of group names that grant admin permission for the server admin website.
Users who belong to any of these groups will be granted admin privileges, regardless of their username.

This is useful when you want to grant admin access based on group membership rather than individual user identifiers.
Groups are typically provided by OAuth/OIDC providers in the "wlcg.groups" claim of the authentication token.

Note: This works in conjunction with Server.UIAdminUsers. A user can be granted admin access either by being
listed in Server.UIAdminUsers or by belonging to a group listed in Server.UIAdminGroups.
type: stringSlice
default: []
components: ["cache", "director", "origin", "registry"]
---
name: Server.StartupTimeout
description: |+
The amount of time the pelican server will wait for its components and services to startup.
Expand Down
1 change: 1 addition & 0 deletions param/parameters.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions param/parameters_struct.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

59 changes: 46 additions & 13 deletions web_ui/authentication.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ func setLoginCookie(ctx *gin.Context, userRecord *database.User, groups []string
// For backwards compatibility (see #398), add additional scopes
// for expert admins who extract the login cookie from their browser
// and use it to query monitoring endpoints directly.
if isAdmin, _ := CheckAdmin(loginCookieTokenCfg.Subject); isAdmin {
if isAdmin, _ := CheckAdmin(loginCookieTokenCfg.Subject, groups); isAdmin {
loginCookieTokenCfg.AddScopes(token_scopes.Monitoring_Query, token_scopes.Monitoring_Scrape)
}

Expand Down Expand Up @@ -368,20 +368,46 @@ func RequireAuthMiddleware(ctx *gin.Context) {
// indicating the error message.
//
// Note that by default it only checks if user == "admin". If you have a custom list of admin identifiers
// to check, you should set Server.UIAdminUsers. See parameters.yaml for details.
func CheckAdmin(user string) (isAdmin bool, message string) {
// to check, you should set Server.UIAdminUsers. If you want to grant admin privileges based on group
// membership, you should set Server.AdminGroups.
//
// groups is an optional parameter. If provided, the function will also check if the user belongs to
// any of the configured admin groups.
func CheckAdmin(user string, groups ...[]string) (isAdmin bool, message string) {
if user == "admin" {
return true, ""
}
adminList := param.Server_UIAdminUsers.GetStringSlice()
if !param.Server_UIAdminUsers.IsSet() {
return false, "Server.UIAdminUsers is not set, and user is not root user. Admin check returns false"

// Check admin groups if groups are provided
if len(groups) > 0 && groups[0] != nil {
adminGroups := param.Server_AdminGroups.GetStringSlice()
if param.Server_AdminGroups.IsSet() && len(adminGroups) > 0 {
userGroups := groups[0]
for _, userGroup := range userGroups {
for _, adminGroup := range adminGroups {
if userGroup == adminGroup {
return true, ""
}
}
}
}
}
for _, admin := range adminList {
if user == admin {
return true, ""

// Check admin users
adminList := param.Server_UIAdminUsers.GetStringSlice()
if param.Server_UIAdminUsers.IsSet() {
for _, admin := range adminList {
if user == admin {
return true, ""
}
}
}

// If neither admin groups nor admin users are configured, and user is not "admin", deny access
if !param.Server_AdminGroups.IsSet() && !param.Server_UIAdminUsers.IsSet() {
return false, "Server.UIAdminUsers and Server.UIAdminGroups are not set, and user is not root user. Admin check returns false"
}

return false, "You don't have permission to perform this action"
}

Expand All @@ -398,7 +424,14 @@ func AdminAuthHandler(ctx *gin.Context) {
})
return
}
isAdmin, msg := CheckAdmin(user)
// Get groups from context if available
var groups []string
if groupsIface, exists := ctx.Get("Groups"); exists {
if groupsSlice, ok := groupsIface.([]string); ok {
groups = groupsSlice
}
}
isAdmin, msg := CheckAdmin(user, groups)
if isAdmin {
ctx.Next()
return
Expand All @@ -419,7 +452,7 @@ func DowntimeAuthHandler(ctx *gin.Context) {
user, _, groups, err := GetUserGroups(ctx)
if user != "" && err == nil {
// User has valid cookie, check if admin
isAdmin, _ := CheckAdmin(user)
isAdmin, _ := CheckAdmin(user, groups)
if isAdmin {
ctx.Set("User", user)
ctx.Set("Groups", groups)
Expand Down Expand Up @@ -641,7 +674,7 @@ func logoutHandler(ctx *gin.Context) {
// Returns the authentication status of the current user, including user id and role
func whoamiHandler(ctx *gin.Context) {
res := WhoAmIRes{}
if user, _, _, err := GetUserGroups(ctx); err != nil || user == "" {
if user, _, groups, err := GetUserGroups(ctx); err != nil || user == "" {
res.Authenticated = false
ctx.JSON(http.StatusOK, res)
} else {
Expand All @@ -650,7 +683,7 @@ func whoamiHandler(ctx *gin.Context) {

// Set header to carry CSRF token
ctx.Header("X-CSRF-Token", csrf.Token(ctx.Request))
isAdmin, _ := CheckAdmin(user)
isAdmin, _ := CheckAdmin(user, groups)
if isAdmin {
res.Role = AdminRole
} else {
Expand Down
195 changes: 194 additions & 1 deletion web_ui/authentication_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ func TestPasswordResetAPI(t *testing.T) {
//Check ok http response
assert.Equal(t, 403, recorderReset.Code)
//Check that success message returned
assert.JSONEq(t, `{"msg":"Server.UIAdminUsers is not set, and user is not root user. Admin check returns false", "status":"error"}`, recorderReset.Body.String())
assert.JSONEq(t, `{"msg":"Server.UIAdminUsers and Server.UIAdminGroups are not set, and user is not root user. Admin check returns false", "status":"error"}`, recorderReset.Body.String())
})

//Invoking password reset without a cookie should result in failure
Expand Down Expand Up @@ -510,6 +510,178 @@ func TestWhoamiAPI(t *testing.T) {
})
}

func TestCheckAdmin(t *testing.T) {
testCases := []struct {
name string
user string
groups []string
adminUsers []string
adminGroups []string
expectedAdmin bool
expectedMsg string
}{
{
name: "root-admin-user",
user: "admin",
groups: nil,
adminUsers: nil,
adminGroups: nil,
expectedAdmin: true,
expectedMsg: "",
},
{
name: "user-in-admin-users-list",
user: "admin1",
groups: nil,
adminUsers: []string{"admin1", "admin2"},
adminGroups: nil,
expectedAdmin: true,
expectedMsg: "",
},
{
name: "user-not-in-admin-users-list",
user: "user1",
groups: nil,
adminUsers: []string{"admin1", "admin2"},
adminGroups: nil,
expectedAdmin: false,
expectedMsg: "You don't have permission to perform this action",
},
{
name: "user-in-admin-group",
user: "user1",
groups: []string{"pelican-admins"},
adminUsers: nil,
adminGroups: []string{"pelican-admins"},
expectedAdmin: true,
expectedMsg: "",
},
{
name: "user-in-multiple-groups-one-admin",
user: "user1",
groups: []string{"pelican-users", "pelican-admins", "other-group"},
adminUsers: nil,
adminGroups: []string{"pelican-admins"},
expectedAdmin: true,
expectedMsg: "",
},
{
name: "user-not-in-admin-group",
user: "user1",
groups: []string{"pelican-users"},
adminUsers: nil,
adminGroups: []string{"pelican-admins"},
expectedAdmin: false,
expectedMsg: "You don't have permission to perform this action",
},
{
name: "user-in-admin-group-and-admin-users",
user: "user1",
groups: []string{"pelican-admins"},
adminUsers: []string{"user1"},
adminGroups: []string{"pelican-admins"},
expectedAdmin: true,
expectedMsg: "",
},
{
name: "user-in-admin-group-not-in-admin-users",
user: "user1",
groups: []string{"pelican-admins"},
adminUsers: []string{"admin1"},
adminGroups: []string{"pelican-admins"},
expectedAdmin: true,
expectedMsg: "",
},
{
name: "user-in-admin-users-not-in-admin-group",
user: "user1",
groups: []string{"pelican-users"},
adminUsers: []string{"user1"},
adminGroups: []string{"pelican-admins"},
expectedAdmin: true,
expectedMsg: "",
},
{
name: "user-with-empty-groups",
user: "user1",
groups: []string{},
adminUsers: nil,
adminGroups: []string{"pelican-admins"},
expectedAdmin: false,
expectedMsg: "You don't have permission to perform this action",
},
{
name: "user-with-nil-groups",
user: "user1",
groups: nil,
adminUsers: nil,
adminGroups: []string{"pelican-admins"},
expectedAdmin: false,
expectedMsg: "You don't have permission to perform this action",
},
{
name: "multiple-admin-groups-user-in-one",
user: "user1",
groups: []string{"pelican-users"},
adminUsers: nil,
adminGroups: []string{"pelican-admins", "pelican-users", "other-admins"},
expectedAdmin: true,
expectedMsg: "",
},
{
name: "no-admin-config-no-groups",
user: "user1",
groups: nil,
adminUsers: nil,
adminGroups: nil,
expectedAdmin: false,
expectedMsg: "Server.UIAdminUsers and Server.UIAdminGroups are not set, and user is not root user. Admin check returns false",
},
{
name: "admin-groups-empty-list",
user: "user1",
groups: []string{"pelican-admins"},
adminUsers: nil,
adminGroups: []string{},
expectedAdmin: false,
expectedMsg: "You don't have permission to perform this action",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
server_utils.ResetTestState()

// Setup admin users config
// Only set if explicitly provided (nil means not set, empty slice means set but empty)
if tc.adminUsers != nil {
viper.Set(param.Server_UIAdminUsers.GetName(), tc.adminUsers)
}

// Setup admin groups config
// Only set if explicitly provided (nil means not set, empty slice means set but empty)
if tc.adminGroups != nil {
viper.Set(param.Server_AdminGroups.GetName(), tc.adminGroups)
}

// Call CheckAdmin
var isAdmin bool
var msg string
if tc.groups != nil {
isAdmin, msg = CheckAdmin(tc.user, tc.groups)
} else {
isAdmin, msg = CheckAdmin(tc.user)
}

// Verify results
assert.Equal(t, tc.expectedAdmin, isAdmin, "Admin status mismatch for user %s", tc.user)
if tc.expectedMsg != "" {
assert.Equal(t, tc.expectedMsg, msg, "Error message mismatch")
}
})
}
}

func TestAdminAuthHandler(t *testing.T) {
// Define test cases
testCases := []struct {
Expand Down Expand Up @@ -569,6 +741,27 @@ func TestAdminAuthHandler(t *testing.T) {
},
expectedCode: http.StatusOK,
},
{
name: "admin-group-access",
setupUserFunc: func(ctx *gin.Context) {
viper.Set(param.Server_UIAdminUsers.GetName(), []string{})
viper.Set(param.Server_AdminGroups.GetName(), []string{"pelican-admins"})
ctx.Set("User", "user1")
ctx.Set("Groups", []string{"pelican-admins"})
},
expectedCode: http.StatusOK,
},
{
name: "non-admin-group-access",
setupUserFunc: func(ctx *gin.Context) {
viper.Set(param.Server_UIAdminUsers.GetName(), []string{})
viper.Set(param.Server_AdminGroups.GetName(), []string{"pelican-admins"})
ctx.Set("User", "user1")
ctx.Set("Groups", []string{"pelican-users"})
},
expectedCode: http.StatusForbidden,
expectedError: "You don't have permission to perform this action",
},
}

// Initialize Gin and set it to test mode
Expand Down
4 changes: 2 additions & 2 deletions web_ui/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ func handleWebUIRedirect(ctx *gin.Context) {
func handleWebUIAuth(ctx *gin.Context) {
requestPath := ctx.Param("requestPath")
db := authDB.Load()
user, _, _, err := GetUserGroups(ctx)
user, _, groups, err := GetUserGroups(ctx)

// Skip auth check for static files other than html pages
if path.Ext(requestPath) != "" && path.Ext(requestPath) != ".html" {
Expand Down Expand Up @@ -338,7 +338,7 @@ func handleWebUIAuth(ctx *gin.Context) {

// If rootPage requires admin privilege
if slices.Contains(adminAccessPages, rootPage) {
isAdmin, _ := CheckAdmin(user)
isAdmin, _ := CheckAdmin(user, groups)
if isAdmin {

// If user is admin, pass the check
Expand Down
Loading