diff --git a/docs/parameters.yaml b/docs/parameters.yaml index 49c577031..596cc61b4 100644 --- a/docs/parameters.yaml +++ b/docs/parameters.yaml @@ -2520,6 +2520,24 @@ 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. + + Group information is obtained from the issuer configuration (`Issuer.GroupSource`). Depending on the group source: + - If `Issuer.GroupSource` is `file`: Groups are read from the file specified by `Issuer.GroupFile`. + - If `Issuer.GroupSource` is `oidc`: Groups are extracted from the OIDC provider token using the claim specified by `Issuer.OIDCGroupClaim` (defaults to "groups"). + - If `Issuer.GroupSource` is `internal`: Groups are read from the Pelican server's internal SQLite database. + + 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.AdminGroups. +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. diff --git a/origin/collections.go b/origin/collections.go index 19e6e5182..4d7b33a24 100644 --- a/origin/collections.go +++ b/origin/collections.go @@ -464,7 +464,7 @@ func handleUpdateCollection(ctx *gin.Context) { return } - isAdmin, _ := web_ui.CheckAdmin(user) + isAdmin, _ := web_ui.CheckAdmin(user, groups) var visibility database.Visibility if req.Visibility != nil { @@ -885,7 +885,7 @@ func handlePutCollectionMetadata(ctx *gin.Context) { return } - isAdmin, _ := web_ui.CheckAdmin(user) + isAdmin, _ := web_ui.CheckAdmin(user, groups) err = database.UpsertCollectionMetadata(database.ServerDatabase, ctx.Param("id"), user, groups, key, value, isAdmin) if err != nil { @@ -941,7 +941,7 @@ func handleDeleteCollectionMetadata(ctx *gin.Context) { return } - isAdmin, _ := web_ui.CheckAdmin(user) + isAdmin, _ := web_ui.CheckAdmin(user, groups) err = database.DeleteCollectionMetadata(database.ServerDatabase, ctx.Param("id"), user, groups, key, isAdmin) if err != nil { @@ -1056,7 +1056,7 @@ func handleDeleteCollection(ctx *gin.Context) { } // we will use this check to determine if we can bypass the collection owner check - isAdmin, _ := web_ui.CheckAdmin(user) + isAdmin, _ := web_ui.CheckAdmin(user, groups) err = database.DeleteCollection(database.ServerDatabase, ctx.Param("id"), user, groups, isAdmin) if err != nil { @@ -1175,7 +1175,7 @@ func handleGrantCollectionAcl(ctx *gin.Context) { return } - isAdmin, _ := web_ui.CheckAdmin(user) + isAdmin, _ := web_ui.CheckAdmin(user, groups) err = database.GrantCollectionAcl(database.ServerDatabase, ctx.Param("id"), user, groups, req.GroupID, role, req.ExpiresAt, isAdmin) if err != nil { @@ -1248,7 +1248,7 @@ func handleRevokeCollectionAcl(ctx *gin.Context) { return } - isAdmin, _ := web_ui.CheckAdmin(user) + isAdmin, _ := web_ui.CheckAdmin(user, groups) err = database.RevokeCollectionAcl(database.ServerDatabase, ctx.Param("id"), user, groups, req.GroupID, role, isAdmin) if err != nil { diff --git a/param/parameters.go b/param/parameters.go index 3a4f5dd58..056c24fdf 100644 --- a/param/parameters.go +++ b/param/parameters.go @@ -320,6 +320,7 @@ var ( Origin_ExportVolumes = StringSliceParam{"Origin.ExportVolumes"} Origin_ScitokensRestrictedPaths = StringSliceParam{"Origin.ScitokensRestrictedPaths"} Registry_AdminUsers = StringSliceParam{"Registry.AdminUsers"} + Server_AdminGroups = StringSliceParam{"Server.AdminGroups"} Server_DirectorUrls = StringSliceParam{"Server.DirectorUrls"} Server_Modules = StringSliceParam{"Server.Modules"} Server_UIAdminUsers = StringSliceParam{"Server.UIAdminUsers"} diff --git a/param/parameters_struct.go b/param/parameters_struct.go index a897e3d30..d7fe8394b 100644 --- a/param/parameters_struct.go +++ b/param/parameters_struct.go @@ -297,6 +297,7 @@ type Config struct { } `mapstructure:"registry" yaml:"Registry"` Server struct { AdLifetime time.Duration `mapstructure:"adlifetime" yaml:"AdLifetime"` + AdminGroups []string `mapstructure:"admingroups" yaml:"AdminGroups"` AdvertisementInterval time.Duration `mapstructure:"advertisementinterval" yaml:"AdvertisementInterval"` DbLocation string `mapstructure:"dblocation" yaml:"DbLocation"` DirectorUrls []string `mapstructure:"directorurls" yaml:"DirectorUrls"` @@ -674,6 +675,7 @@ type configWithType struct { } Server struct { AdLifetime struct { Type string; Value time.Duration } + AdminGroups struct { Type string; Value []string } AdvertisementInterval struct { Type string; Value time.Duration } DbLocation struct { Type string; Value string } DirectorUrls struct { Type string; Value []string } diff --git a/registry/registry_ui.go b/registry/registry_ui.go index 7c763c0c3..42b43fe97 100644 --- a/registry/registry_ui.go +++ b/registry/registry_ui.go @@ -342,9 +342,16 @@ func getNamespaceRegFields(ctx *gin.Context) { // POST /namespaces // PUT /namespaces/:id func createUpdateNamespace(ctx *gin.Context, isUpdate bool) { - user := ctx.GetString("User") accessToken := ctx.Query("access_token") - isAdmin, _ := web_ui.CheckAdmin(user) + user, _, groups, err := web_ui.GetUserGroups(ctx) + if err != nil { + log.Error("Failed to get user groups: ", err) + ctx.JSON(http.StatusInternalServerError, server_structs.SimpleApiResp{ + Status: server_structs.RespFailed, + Msg: "Failed to get user groups"}) + return + } + isAdmin, _ := web_ui.CheckAdmin(user, groups) id := 0 // namespace ID when doing update, will be populated later if user == "" { @@ -654,7 +661,14 @@ func createUpdateNamespace(ctx *gin.Context, isUpdate bool) { // // GET /namesapces/:id func getNamespace(ctx *gin.Context) { - user := ctx.GetString("User") + user, _, groups, err := web_ui.GetUserGroups(ctx) + if err != nil { + log.Error("Failed to get user groups: ", err) + ctx.JSON(http.StatusInternalServerError, server_structs.SimpleApiResp{ + Status: server_structs.RespFailed, + Msg: "Failed to get user groups"}) + return + } idStr := ctx.Param("id") accessToken := ctx.Query("access_token") id, err := strconv.Atoi(idStr) @@ -681,7 +695,7 @@ func getNamespace(ctx *gin.Context) { return } - isAdmin, _ := web_ui.CheckAdmin(user) + isAdmin, _ := web_ui.CheckAdmin(user, groups) belongsTo := false if !isAdmin { // Not admin, need to check if the namespace belongs to the user diff --git a/web_ui/authentication.go b/web_ui/authentication.go index 21255ec12..1a178b1be 100644 --- a/web_ui/authentication.go +++ b/web_ui/authentication.go @@ -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) } @@ -368,20 +368,42 @@ 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. +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 { + adminGroups := param.Server_AdminGroups.GetStringSlice() + if param.Server_AdminGroups.IsSet() && len(adminGroups) > 0 { + for _, userGroup := range groups { + 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" } @@ -398,7 +420,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 @@ -419,7 +448,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) @@ -641,7 +670,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 { @@ -650,7 +679,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 { diff --git a/web_ui/authentication_test.go b/web_ui/authentication_test.go index c54ac1b18..6df8a57be 100644 --- a/web_ui/authentication_test.go +++ b/web_ui/authentication_test.go @@ -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 @@ -510,6 +510,174 @@ 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 + isAdmin, msg = CheckAdmin(tc.user, tc.groups) + + // 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 { @@ -569,6 +737,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 diff --git a/web_ui/ui.go b/web_ui/ui.go index 8cd0ee82e..e08784197 100644 --- a/web_ui/ui.go +++ b/web_ui/ui.go @@ -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" { @@ -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