From 47aaf150784bdc52c838a664e3ef29079c6c33e7 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Tue, 1 Aug 2023 20:39:48 -0700 Subject: [PATCH] Admin Groups & Permissions Add a permission system for admin users so you can lock down specific admins to a narrower set of features instead of them all having omnipotent powers. * New page: Admin Dashboard -> Admin Permissions Management * Permissions are handled in the form of 'scopes' relevant to each feature or action on the site. Scopes are assigned to Groups, and in turn, admin user accounts are placed in those Groups. * The Superusers group (scope '*') has wildcard permission to all scopes. The permissions dashboard has a create-once action to initialize the Superusers for the first admin who clicks on it, and places that admin in the group. The following are the exhaustive list of permission changes on the site: * Moderator scopes: * Chat room (enter the room with Operator permission) * Forums (can edit or delete user posts on the forum) * Photo Gallery (can see all private/friends-only photos on the site gallery or user profile pages) * Certification photos (with nuanced sub-action permissions) * Approve: has access to the Pending tab to act on incoming pictures * List: can paginate thru past approved/rejected photos * View: can bring up specific user cert photo from their profile * The minimum requirement is Approve or else no cert photo page will load for your admin user. * User Actions (each action individually scoped) * Impersonate * Ban * Delete * Promote to admin * Inner circle whitelist: no longer are admins automatically part of the inner circle unless they have a specialized scope attached. The AdminRequired decorator may also apply scopes on an entire admin route. The following routes have scopes to limit them: * Forum Admin (manage forums and their settings) * Remove from inner circle --- Makefile | 6 +- pkg/config/admin_scopes.go | 66 ++++++ pkg/config/admin_scopes_test.go | 21 ++ pkg/controller/admin/scopes.go | 195 +++++++++++++++++ pkg/controller/admin/user_actions.go | 29 +++ pkg/controller/chat/chat.go | 2 +- pkg/controller/forum/new_post.go | 2 +- pkg/controller/photo/certification.go | 32 +++ pkg/controller/photo/site_gallery.go | 5 + pkg/controller/photo/user_gallery.go | 2 +- pkg/middleware/authentication.go | 14 +- pkg/models/admin_scopes.go | 290 ++++++++++++++++++++++++++ pkg/models/friend.go | 4 +- pkg/models/models.go | 2 + pkg/models/photo.go | 3 +- pkg/models/thread.go | 2 +- pkg/models/user.go | 46 +++- pkg/models/user_inner_circle.go | 5 +- pkg/router/router.go | 15 +- web/templates/admin/dashboard.html | 8 +- web/templates/admin/scopes.html | 239 +++++++++++++++++++++ web/templates/photo/gallery.html | 2 +- 22 files changed, 967 insertions(+), 23 deletions(-) create mode 100644 pkg/config/admin_scopes.go create mode 100644 pkg/config/admin_scopes_test.go create mode 100644 pkg/controller/admin/scopes.go create mode 100644 pkg/models/admin_scopes.go create mode 100644 web/templates/admin/scopes.html diff --git a/Makefile b/Makefile index b036d87..40e83f7 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,10 @@ setup: build: go build $(LDFLAGS) -o nonshy cmd/nonshy/main.go +.PHONY: test +test: + go test ./... + .PHONY: run run: - go run cmd/nonshy/main.go web --debug \ No newline at end of file + go run cmd/nonshy/main.go web --debug diff --git a/pkg/config/admin_scopes.go b/pkg/config/admin_scopes.go new file mode 100644 index 0000000..c561b57 --- /dev/null +++ b/pkg/config/admin_scopes.go @@ -0,0 +1,66 @@ +package config + +// All available admin scopes +const ( + // Social moderation over the chat and forums. + // - Chat: have operator controls in the chat room + // - Forum: ability to edit and delete user posts + // - Photo: omniscient view of all gallery photos, can edit/delete photos + // - Inner circle: ability to remove users from it + ScopeChatModerator = "social.moderator.chat" + ScopeForumModerator = "social.moderator.forum" + ScopePhotoModerator = "social.moderator.photo" + ScopeCircleModerator = "social.moderator.inner-circle" + + // Certification photo management + // - Approve: ability to respond to pending certification pics + // - List: paginate thru all approved or rejected photos + // - View: inspect specific user photos + ScopeCertificationApprove = "certification.approve" + ScopeCertificationList = "certification.list" + ScopeCertificationView = "certification.view" + + // Website administration + // - Forum: ability to manage available forums + // - Scopes: ability to manage admin groups & scopes + ScopeForumAdmin = "admin.forum.manage" + ScopeAdminScopeAdmin = "admin.scope.manage" + + // User account admin + // - Impersonate: ability to log in as a user account + // - Ban: ability to ban/unban users + // - Delete: ability to delete user accounts + ScopeUserImpersonate = "admin.user.impersonate" + ScopeUserBan = "admin.user.ban" + ScopeUserPromote = "admin.user.promote" + ScopeUserDelete = "admin.user.delete" + + // Special scope to mark an admin automagically in the Inner Circle + ScopeIsInnerCircle = "admin.override.inner-circle" +) + +// Number of expected scopes for unit test and validation. +const QuantityAdminScopes = 14 + +// The specially named Superusers group. +const AdminGroupSuperusers = "Superusers" + +// ListAdminScopes returns the listing of all available admin scopes. +func ListAdminScopes() []string { + return []string{ + ScopeChatModerator, + ScopeForumModerator, + ScopePhotoModerator, + ScopeCircleModerator, + ScopeCertificationApprove, + ScopeCertificationList, + ScopeCertificationView, + ScopeForumAdmin, + ScopeAdminScopeAdmin, + ScopeUserImpersonate, + ScopeUserBan, + ScopeUserDelete, + ScopeUserPromote, + ScopeIsInnerCircle, + } +} diff --git a/pkg/config/admin_scopes_test.go b/pkg/config/admin_scopes_test.go new file mode 100644 index 0000000..1d582f1 --- /dev/null +++ b/pkg/config/admin_scopes_test.go @@ -0,0 +1,21 @@ +package config_test + +import ( + "testing" + + "code.nonshy.com/nonshy/website/pkg/config" +) + +// TestAdminScopesCount validates that all named admin scopes are +// returned by the scope list function. +func TestAdminScopesCount(t *testing.T) { + var scopes = config.ListAdminScopes() + if len(scopes) != config.QuantityAdminScopes { + t.Errorf( + "The list of scopes returned by ListAdminScopes doesn't match the expected count. "+ + "Expected %d, got %d", + config.QuantityAdminScopes, + len(scopes), + ) + } +} diff --git a/pkg/controller/admin/scopes.go b/pkg/controller/admin/scopes.go new file mode 100644 index 0000000..8ee88f8 --- /dev/null +++ b/pkg/controller/admin/scopes.go @@ -0,0 +1,195 @@ +package admin + +import ( + "net/http" + "strconv" + "strings" + + "code.nonshy.com/nonshy/website/pkg/config" + "code.nonshy.com/nonshy/website/pkg/models" + "code.nonshy.com/nonshy/website/pkg/session" + "code.nonshy.com/nonshy/website/pkg/templates" +) + +// Scopes controller (/admin/scopes) +func Scopes() http.HandlerFunc { + tmpl := templates.Must("admin/scopes.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Query parameters. + var ( + intent = r.FormValue("intent") + editGroupIDStr = r.FormValue("id") + ) + + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Couldn't get your current user: %s", err) + } + + _ = currentUser + + // List all of the admin users, groups & scopes. + adminUsers, err := models.ListAdminUsers() + if err != nil { + session.FlashError(w, r, "ListAdminUsers: %s", err) + } + adminGroups, err := models.ListAdminGroups() + if err != nil { + session.FlashError(w, r, "ListAdminGroups: %s", err) + } + + // Does the Superusers group exist yet? + var needSuperuserInit = true + for _, group := range adminGroups { + if group.Name == config.AdminGroupSuperusers { + needSuperuserInit = false + break + } + } + + // Validate that the Edit Group ID exists. + var ( + editGroupID int + editGroup = &models.AdminGroup{} + ) + if editGroupIDStr != "" { + groupID, err := strconv.Atoi(editGroupIDStr) + if err != nil { + session.FlashError(w, r, "Group ID is not a valid integer") + templates.Redirect(w, r.URL.Path) + return + } + + var found bool + for _, group := range adminGroups { + if group.ID == uint64(groupID) { + editGroup = group + found = true + break + } + } + + if !found && groupID > 0 { + session.FlashError(w, r, "Group ID not found.") + templates.Redirect(w, r.URL.Path) + return + } + + editGroupID = groupID + } + + // POST event handlers. + if r.Method == http.MethodPost { + // Scope check. + if !currentUser.HasAdminScope(config.ScopeAdminScopeAdmin) && intent != "init-superusers" { + session.FlashError(w, r, "Missing admin scope: %s", config.ScopeAdminScopeAdmin) + templates.Redirect(w, r.URL.Path) + return + } + + switch intent { + case "init-superusers": + // Initialize the Superusers group, if it does not already exist. + if !needSuperuserInit { + session.FlashError(w, r, "Could not initialize the Superusers group: it already exists.") + break + } + + group, err := models.CreateAdminGroup(config.AdminGroupSuperusers, []string{"*"}) + if err != nil { + session.FlashError(w, r, "Couldn't create Superusers group: %s", err) + break + } + + // Add the current admin user to it. + if err := group.AddUser(currentUser); err != nil { + session.FlashError(w, r, "Couldn't add you to the Superusers group: %s", err) + break + } + + session.Flash(w, r, "The Superusers group has been initialized and you placed in it.") + case "save": + // Create or Save an AdminGroup. + var ( + groupName = r.PostFormValue("name") + groupScopes = strings.Split(r.PostFormValue("scopes"), "\n") + groupUsers = r.PostForm["username"] + ) + + if editGroupID == 0 { + // New group: easiest option. + group, err := models.CreateAdminGroup(groupName, groupScopes) + if err != nil { + session.FlashError(w, r, "Couldn't create new group: %s", err) + break + } + + // Apply the user list to it. + added, removed, err := group.ReplaceUsers(groupUsers) + if err != nil { + session.FlashError(w, r, "Couldn't save users in this group: %s", err) + break + } + + session.Flash(w, r, "Saved admin group with %d scopes.", len(groupScopes)) + if len(added) > 0 { + session.Flash(w, r, "Added %s to the group.", strings.Join(added, ", ")) + } + if len(removed) > 0 { + session.Flash(w, r, "Removed %s from the group.", strings.Join(removed, ", ")) + } + } else { + // Updating the existing group. + if err := editGroup.ReplaceScopes(groupScopes); err != nil { + session.FlashError(w, r, "Couldn't replace scopes: %s", err) + break + } + + added, removed, err := editGroup.ReplaceUsers(groupUsers) + if err != nil { + session.FlashError(w, r, "Couldn't save users in this group: %s", err) + break + } + + session.Flash(w, r, "Saved admin group with %d scopes.", len(groupScopes)) + if len(added) > 0 { + session.Flash(w, r, "Added %s to the group.", strings.Join(added, ", ")) + } + if len(removed) > 0 { + session.Flash(w, r, "Removed %s from the group.", strings.Join(removed, ", ")) + } + } + case "delete": + if editGroupID == 0 { + session.FlashError(w, r, "Can't delete group: no group ID") + } else { + err := editGroup.Delete() + if err != nil { + session.FlashError(w, r, "Couldn't delete group: %s", err) + } else { + session.Flash(w, r, "Group deleted!") + } + } + default: + session.FlashError(w, r, "Unsupported intent: %s", intent) + } + + templates.Redirect(w, r.URL.Path) + return + } + + var vars = map[string]interface{}{ + "Intent": intent, + "AdminUsers": adminUsers, + "AdminGroups": adminGroups, + "AdminScopes": config.ListAdminScopes(), + "NeedSuperuserInit": needSuperuserInit, + "EditGroupID": editGroupID, + "EditGroup": editGroup, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/admin/user_actions.go b/pkg/controller/admin/user_actions.go index d8166df..3b2b1bf 100644 --- a/pkg/controller/admin/user_actions.go +++ b/pkg/controller/admin/user_actions.go @@ -4,6 +4,7 @@ import ( "net/http" "strconv" + "code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models/deletion" "code.nonshy.com/nonshy/website/pkg/session" @@ -47,6 +48,13 @@ func UserActions() http.HandlerFunc { switch intent { case "impersonate": + // Scope check. + if !currentUser.HasAdminScope(config.ScopeUserImpersonate) { + session.FlashError(w, r, "Missing admin scope: %s", config.ScopeUserImpersonate) + templates.Redirect(w, "/admin") + return + } + if confirm { if err := session.ImpersonateUser(w, r, user, currentUser, reason); err != nil { session.FlashError(w, r, "Failed to impersonate user: %s", err) @@ -57,6 +65,13 @@ func UserActions() http.HandlerFunc { } } case "ban": + // Scope check. + if !currentUser.HasAdminScope(config.ScopeUserBan) { + session.FlashError(w, r, "Missing admin scope: %s", config.ScopeUserBan) + templates.Redirect(w, "/admin") + return + } + if confirm { status := r.PostFormValue("status") if status == "active" { @@ -71,6 +86,13 @@ func UserActions() http.HandlerFunc { return } case "promote": + // Scope check. + if !currentUser.HasAdminScope(config.ScopeUserPromote) { + session.FlashError(w, r, "Missing admin scope: %s", config.ScopeUserPromote) + templates.Redirect(w, "/admin") + return + } + if confirm { action := r.PostFormValue("action") user.IsAdmin = action == "promote" @@ -80,6 +102,13 @@ func UserActions() http.HandlerFunc { return } case "delete": + // Scope check. + if !currentUser.HasAdminScope(config.ScopeUserDelete) { + session.FlashError(w, r, "Missing admin scope: %s", config.ScopeUserDelete) + templates.Redirect(w, "/admin") + return + } + if confirm { if err := deletion.DeleteUser(user); err != nil { session.FlashError(w, r, "Failed when deleting the user: %s", err) diff --git a/pkg/controller/chat/chat.go b/pkg/controller/chat/chat.go index 6182a0d..e97ed93 100644 --- a/pkg/controller/chat/chat.go +++ b/pkg/controller/chat/chat.go @@ -82,7 +82,7 @@ func Landing() http.HandlerFunc { // Create the JWT claims. claims := Claims{ - IsAdmin: currentUser.IsAdmin, + IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator), Avatar: avatar, ProfileURL: "/u/" + currentUser.Username, Nickname: currentUser.NameOrUsername(), diff --git a/pkg/controller/forum/new_post.go b/pkg/controller/forum/new_post.go index 3329051..6d529b7 100644 --- a/pkg/controller/forum/new_post.go +++ b/pkg/controller/forum/new_post.go @@ -115,7 +115,7 @@ func NewPost() http.HandlerFunc { comment = found // Verify that it is indeed OUR comment. - if currentUser.ID != comment.UserID && !currentUser.IsAdmin { + if currentUser.ID != comment.UserID && !currentUser.HasAdminScope(config.ScopeForumModerator) { templates.ForbiddenPage(w, r) return } diff --git a/pkg/controller/photo/certification.go b/pkg/controller/photo/certification.go index 7d2eeee..a61bc17 100644 --- a/pkg/controller/photo/certification.go +++ b/pkg/controller/photo/certification.go @@ -180,8 +180,33 @@ func AdminCertification() http.HandlerFunc { session.FlashError(w, r, "Couldn't get CurrentUser: %s", err) } + // Scope check based on view. + switch view { + case "pending": + // Scope check. + if !currentUser.HasAdminScope(config.ScopeCertificationApprove) { + session.FlashError(w, r, "Missing admin scope: %s", config.ScopeCertificationApprove) + templates.Redirect(w, "/admin") + return + } + case "approved", "rejected": + // Scope check. + if !currentUser.HasAdminScope(config.ScopeCertificationList) { + session.FlashError(w, r, "Missing admin scope: %s", config.ScopeCertificationList) + templates.Redirect(w, "/admin") + return + } + } + // Short circuit the GET view for username/email search (exact match) if username := r.FormValue("username"); username != "" { + // Scope check. + if !currentUser.HasAdminScope(config.ScopeCertificationView) { + session.FlashError(w, r, "Missing admin scope: %s", config.ScopeCertificationView) + templates.Redirect(w, "/admin") + return + } + user, err := models.FindUser(username) if err != nil { session.FlashError(w, r, "Username or email '%s' not found.", username) @@ -210,6 +235,13 @@ func AdminCertification() http.HandlerFunc { // Making a verdict? if r.Method == http.MethodPost { + // Scope check. + if !currentUser.HasAdminScope(config.ScopeCertificationApprove) { + session.FlashError(w, r, "Missing admin scope: %s", config.ScopeCertificationApprove) + templates.Redirect(w, r.URL.Path) + return + } + var ( comment = r.PostFormValue("comment") verdict = r.PostFormValue("verdict") diff --git a/pkg/controller/photo/site_gallery.go b/pkg/controller/photo/site_gallery.go index 9758233..a369bfe 100644 --- a/pkg/controller/photo/site_gallery.go +++ b/pkg/controller/photo/site_gallery.go @@ -57,6 +57,11 @@ func SiteGallery() http.HandlerFunc { // Is the current viewer shy? var isShy = currentUser.IsShy() + // Admin scope warning. + if adminView && !currentUser.HasAdminScope(config.ScopePhotoModerator) { + session.FlashError(w, r, "Missing admin scope: %s", config.ScopePhotoModerator) + } + // Get the page of photos. pager := &models.Pagination{ Page: 1, diff --git a/pkg/controller/photo/user_gallery.go b/pkg/controller/photo/user_gallery.go index 50f3087..97744ed 100644 --- a/pkg/controller/photo/user_gallery.go +++ b/pkg/controller/photo/user_gallery.go @@ -95,7 +95,7 @@ func UserPhotos() http.HandlerFunc { // What set of visibilities to query? visibility := []models.PhotoVisibility{models.PhotoPublic} - if isOwnPhotos || isGrantee || currentUser.IsAdmin { + if isOwnPhotos || isGrantee || currentUser.HasAdminScope(config.ScopePhotoModerator) { visibility = append(visibility, models.PhotoFriends, models.PhotoPrivate) } else if models.AreFriends(user.ID, currentUser.ID) { visibility = append(visibility, models.PhotoFriends) diff --git a/pkg/middleware/authentication.go b/pkg/middleware/authentication.go index bc20902..db3fc35 100644 --- a/pkg/middleware/authentication.go +++ b/pkg/middleware/authentication.go @@ -60,7 +60,7 @@ func LoginRequired(handler http.Handler) http.Handler { } // AdminRequired middleware. -func AdminRequired(handler http.Handler) http.Handler { +func AdminRequired(scope string, handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // User must be logged in. @@ -77,12 +77,22 @@ func AdminRequired(handler http.Handler) http.Handler { // Admin required. if !currentUser.IsAdmin { - log.Error("AdminRequired: %s", err) errhandler := templates.MakeErrorPage("Admin Required", "You do not have permission for this page.", http.StatusForbidden) errhandler.ServeHTTP(w, r.WithContext(ctx)) return } + // Ensure the admin scope. + if scope != "" && !currentUser.HasAdminScope(scope) { + errhandler := templates.MakeErrorPage( + "Admin Scope Required", + "Missing required admin scope: "+scope, + http.StatusForbidden, + ) + errhandler.ServeHTTP(w, r.WithContext(ctx)) + return + } + handler.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/pkg/models/admin_scopes.go b/pkg/models/admin_scopes.go new file mode 100644 index 0000000..17c52c7 --- /dev/null +++ b/pkg/models/admin_scopes.go @@ -0,0 +1,290 @@ +package models + +import ( + "errors" + "fmt" + "regexp" + "sort" + "strings" + "time" + + "code.nonshy.com/nonshy/website/pkg/log" + "gorm.io/gorm" +) + +// AdminGroup table gives a name to a set of admin permission scopes. +type AdminGroup struct { + ID uint64 `gorm:"primaryKey"` + Name string `gorm:"index"` + CreatedAt time.Time + UpdatedAt time.Time + Scopes []AdminScope `gorm:"foreignKey:GroupID"` + Users []*User `gorm:"many2many:admin_group_users;"` +} + +// AdminScope table maps admin group IDs to named scopes (which may contain wildcards). +type AdminScope struct { + ID uint64 `gorm:"primaryKey"` + GroupID uint64 `gorm:"index"` + Scope string +} + +// Preload related tables for the AdminGroup. +func (g *AdminGroup) Preload() *gorm.DB { + return DB.Preload("Scopes").Preload("Users") +} + +// HasAdminScope returns whether the user is an admin and has the requested scope. +func (u *User) HasAdminScope(scope string) bool { + if !u.IsAdmin { + return false + } + + for _, group := range u.AdminGroups { + for _, compare := range group.Scopes { + // Scope has a wildcard? + if strings.ContainsRune(compare.Scope, '*') { + re := regexp.MustCompile( + fmt.Sprintf(`^%s$`, strings.ReplaceAll(compare.Scope, "*", ".+?")), + ) + if res := re.FindStringSubmatch(scope); len(res) > 0 { + log.Debug("Regexp scope '%s' matched requested '%s'", compare.Scope, scope) + return true + } + } else if compare.Scope == scope { + return true + } + } + } + + return false +} + +// ListAdminGroups returns the list of admin group (names) the user belongs to. +func (u *User) ListAdminGroups() []string { + var names = []string{} + for _, group := range u.AdminGroups { + names = append(names, group.Name) + } + sort.Strings(names) + return names +} + +// ListAdminScopes returns the list of admin group (names) the user belongs to. +func (u *User) ListAdminScopes() []string { + var ( + names = []string{} + distinct = map[string]interface{}{} + ) + for _, group := range u.AdminGroups { + for _, scope := range group.Scopes { + if _, ok := distinct[scope.Scope]; ok { + continue + } + distinct[scope.Scope] = nil + names = append(names, scope.Scope) + } + } + sort.Strings(names) + return names +} + +// CreateAdminGroup inserts a new admin group. +func CreateAdminGroup(name string, scopes []string) (*AdminGroup, error) { + // Verify the name is unique. + if _, err := FindAdminGroup(name); err == nil { + return nil, fmt.Errorf("that group name already exists: %s", err) + } + + g := &AdminGroup{ + Name: name, + Scopes: []AdminScope{}, + } + for _, scope := range scopes { + scope = strings.TrimSpace(scope) + if scope != "" { + g.Scopes = append(g.Scopes, AdminScope{ + Scope: scope, + }) + } + } + + result := DB.Create(g) + return g, result.Error +} + +// FindAdminGroup by name. +func FindAdminGroup(name string) (*AdminGroup, error) { + if name == "" { + return nil, errors.New("username is required") + } + + g := &AdminGroup{} + result := g.Preload().Where("name = ?", name).Limit(1).First(g) + return g, result.Error +} + +// ListAdminGroups returns all admin groups. +func ListAdminGroups() ([]*AdminGroup, error) { + var groups = []*AdminGroup{} + + result := (&AdminGroup{}).Preload().Order("name asc").Find(&groups) + return groups, result.Error +} + +// ListAdminUsers returns all admin user accounts. +func ListAdminUsers() ([]*User, error) { + var users = []*User{} + + result := (&User{}).Preload().Where("is_admin is true").Order("id asc").Find(&users) + return users, result.Error +} + +// ScopesString returns the scopes as a newline separated string. +func (g *AdminGroup) ScopesString() string { + var scopes = []string{} + for _, scope := range g.Scopes { + scopes = append(scopes, scope.Scope) + } + return strings.Join(scopes, "\n") +} + +// ReplaceScopes sets new scopes for the admin group. +func (g *AdminGroup) ReplaceScopes(scopes []string) error { + // Delete the original scopes. + var replace = []AdminScope{} + for _, scope := range scopes { + scope = strings.TrimSpace(scope) + if scope != "" { + replace = append(replace, AdminScope{ + Scope: scope, + }) + } + } + + err := DB.Model(g).Association("Scopes").Replace(replace) + + // Cleanup orphaned scopes. + if result := DB.Where("group_id IS NULL").Delete(&AdminScope{}); result.Error != nil { + log.Error("AdminGroup.ReplaceScopes: cleanup orphaned scopes: %s", result.Error) + } + + return err +} + +// HasAdmin returns whether the given username is a member of the group. +func (g *AdminGroup) HasAdmin(username string) bool { + for _, user := range g.Users { + if user.Username == username { + return true + } + } + return false +} + +// ReplaceUsers easily adds or removes admin users from a group. +// +// Post the full list of usernames who should be in the group, and it will add or +// remove users as needed and return which users were changed. +func (g *AdminGroup) ReplaceUsers(usernames []string) (added, removed []string, err error) { + added = []string{} + removed = []string{} + + // Map who is currently in the group. + var currentUsernames = map[string]interface{}{} + var allUsernames = []string{} + for _, user := range g.Users { + currentUsernames[user.Username] = nil + allUsernames = append(allUsernames, user.Username) + } + + // Map the incoming username list. + var incomingUsernames = map[string]interface{}{} + + // Who needs added? + for _, username := range usernames { + incomingUsernames[username] = nil + allUsernames = append(allUsernames, username) + if _, ok := currentUsernames[username]; !ok { + added = append(added, username) + } + } + + // Who needs removed? + for username := range currentUsernames { + if _, ok := incomingUsernames[username]; !ok { + removed = append(removed, username) + } + } + + log.Info("AdminGroup(%s).ReplaceUsers: complete list %s (adding: %s) (removing: %s)", + g.Name, + usernames, + added, + removed, + ) + + // Select all affected users from the DB. + usermap, err := MapUsersByUsername(allUsernames) + if err != nil { + return + } + + // Do the needful. + for _, username := range added { + log.Info("AdminGroup(%s).ReplaceUsers: ADD user %s", g.Name, username) + if err := g.AddUser(usermap[username]); err != nil { + return added, removed, fmt.Errorf("adding user %s: %s", username, err) + } + } + for _, username := range removed { + log.Info("AdminGroup(%s).ReplaceUsers: REMOVE user %s", g.Name, username) + if err := g.RemoveUser(usermap[username]); err != nil { + return added, removed, fmt.Errorf("removing user %s: %s", username, err) + } + } + + return +} + +// AddUser puts the given user into the admin group. +func (g *AdminGroup) AddUser(user *User) error { + if user.AdminGroups == nil { + user.AdminGroups = []*AdminGroup{} + } + + // Already exists? + for _, group := range user.AdminGroups { + if group.ID == g.ID { + return nil + } + } + + // Insert them. + user.AdminGroups = append(user.AdminGroups, g) + return user.Save() +} + +// RemoveUser removes a user from the admin group. +func (g *AdminGroup) RemoveUser(user *User) error { + if user.AdminGroups == nil { + return errors.New("user had no admin groups") + } + + return DB.Model(user).Association("AdminGroups").Delete(g) +} + +// Delete the AdminGroup. +func (g *AdminGroup) Delete() error { + // Remove scopes. + if result := DB.Where("group_id = ?", g.ID).Delete(&AdminScope{}); result.Error != nil { + return fmt.Errorf("can't remove scopes for group ID %d: %s", g.ID, result.Error) + } + + // Remove users. + if _, _, err := g.ReplaceUsers([]string{}); err != nil { + return fmt.Errorf("can't remove users for group ID %d: %s", g.ID, err) + } + + return DB.Delete(g).Error +} diff --git a/pkg/models/friend.go b/pkg/models/friend.go index 6722858..1fba397 100644 --- a/pkg/models/friend.go +++ b/pkg/models/friend.go @@ -149,7 +149,7 @@ func FriendIDsInCircle(userId uint64) []uint64 { ).Scan(&userIDs) if err.Error != nil { - log.Error("SQL error collecting circle FriendIDs for %d: %s", userId, err) + log.Error("SQL error collecting circle FriendIDs for %d: %s", userId, err.Error) } return userIDs @@ -174,7 +174,7 @@ func FriendIDsInCircleAreExplicit(userId uint64) []uint64 { ).Scan(&userIDs) if err.Error != nil { - log.Error("SQL error collecting explicit FriendIDs for %d: %s", userId, err) + log.Error("SQL error collecting explicit FriendIDs for %d: %s", userId, err.Error) } return userIDs diff --git a/pkg/models/models.go b/pkg/models/models.go index 68647dd..318168e 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -26,4 +26,6 @@ func AutoMigrate() { DB.AutoMigrate(&CommentPhoto{}) DB.AutoMigrate(&Poll{}) DB.AutoMigrate(&PollVote{}) + DB.AutoMigrate(&AdminGroup{}) + DB.AutoMigrate(&AdminScope{}) } diff --git a/pkg/models/photo.go b/pkg/models/photo.go index f98bc61..da21754 100644 --- a/pkg/models/photo.go +++ b/pkg/models/photo.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/log" "gorm.io/gorm" ) @@ -302,7 +303,7 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot } // Admins see everything on the site (only an admin user can get an admin view). - adminView = user.IsAdmin && adminView + adminView = user.HasAdminScope(config.ScopePhotoModerator) && adminView // Include ourself in our friend IDs. friendIDs = append(friendIDs, userID) diff --git a/pkg/models/thread.go b/pkg/models/thread.go index 9d8c3c9..d31768f 100644 --- a/pkg/models/thread.go +++ b/pkg/models/thread.go @@ -269,7 +269,7 @@ func MapThreadStatistics(threads []*Thread) ThreadStatsMap { ).Group("table_id").Scan(&groups) if err != nil { - log.Error("MapThreadStatistics: SQL error: %s", err) + log.Error("MapThreadStatistics: SQL error: %s", err.Error) } // Map the results in. diff --git a/pkg/models/user.go b/pkg/models/user.go index e986f19..aad98c5 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -36,7 +36,8 @@ type User struct { // Relational tables. ProfileField []ProfileField ProfilePhotoID *uint64 - ProfilePhoto Photo `gorm:"foreignKey:profile_photo_id"` + ProfilePhoto Photo `gorm:"foreignKey:profile_photo_id"` + AdminGroups []*AdminGroup `gorm:"many2many:admin_group_users;"` // Current user's relationship to this user -- not stored in DB. UserRelationship UserRelationship `gorm:"-"` @@ -62,7 +63,7 @@ var UserVisibilityOptions = []UserVisibility{ // Preload related tables for the user (classmethod). func (u *User) Preload() *gorm.DB { - return DB.Preload("ProfileField").Preload("ProfilePhoto") + return DB.Preload("ProfileField").Preload("ProfilePhoto").Preload("AdminGroups.Scopes") } // UserStatus options. @@ -338,6 +339,47 @@ func (um UserMap) Get(id uint64) *User { return nil } +// MapUsersByUsername looks up a set of users in bulk and returns a UsernameMap suitable for templates. +// +// It is like MapUsers but by username instead of ID. +func MapUsersByUsername(usernames []string) (UsernameMap, error) { + var ( + usermap = UsernameMap{} + set = map[string]interface{}{} + distinct = []string{} + ) + + // Uniqueify users. + for _, uid := range usernames { + if _, ok := set[uid]; ok { + continue + } + set[uid] = nil + distinct = append(distinct, uid) + } + + var ( + users = []*User{} + result = (&User{}).Preload().Where("username IN ?", distinct).Find(&users) + ) + + if result.Error == nil { + for _, row := range users { + usermap[row.Username] = row + } + } + + // Assert we got the expected count. + if len(usermap) != len(distinct) { + return usermap, fmt.Errorf("didn't get all expected users (expected %d, got %d)", len(distinct), len(usermap)) + } + + return usermap, result.Error +} + +// UsernameMap helps map a set of users to look up by ID. +type UsernameMap map[string]*User + // NameOrUsername returns the name (if not null or empty) or the username. func (u *User) NameOrUsername() string { if u.Name != nil && len(*u.Name) > 0 { diff --git a/pkg/models/user_inner_circle.go b/pkg/models/user_inner_circle.go index 9c17318..f23e7df 100644 --- a/pkg/models/user_inner_circle.go +++ b/pkg/models/user_inner_circle.go @@ -3,6 +3,7 @@ package models import ( "errors" + "code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/log" ) @@ -10,7 +11,7 @@ import ( // IsInnerCircle returns whether the user is in the inner circle (including if the user is an admin, who is always in the inner circle). func (u *User) IsInnerCircle() bool { - return u.InnerCircle || u.IsAdmin + return u.InnerCircle || u.HasAdminScope(config.ScopeIsInnerCircle) } // AddToInnerCircle adds a user to the circle, sending them a notification in the process. @@ -57,7 +58,7 @@ func RemoveFromInnerCircle(u *User) error { "user_id = ? AND visibility = ?", u.ID, PhotoInnerCircle, ).Update("visibility", PhotoFriends); err != nil { - log.Error("RemoveFromInnerCircle: couldn't update photo visibility: %s", err) + log.Error("RemoveFromInnerCircle: couldn't update photo visibility: %s", err.Error) } // Revoke any historic notification about the circle. diff --git a/pkg/router/router.go b/pkg/router/router.go index c56952b..347448e 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -80,13 +80,14 @@ func New() http.Handler { mux.Handle("/poll/vote", middleware.CertRequired(poll.Vote())) // Admin endpoints. - mux.Handle("/admin", middleware.AdminRequired(admin.Dashboard())) - mux.Handle("/admin/photo/certification", middleware.AdminRequired(photo.AdminCertification())) - mux.Handle("/admin/feedback", middleware.AdminRequired(admin.Feedback())) - mux.Handle("/admin/user-action", middleware.AdminRequired(admin.UserActions())) - mux.Handle("/forum/admin", middleware.AdminRequired(forum.Manage())) - mux.Handle("/forum/admin/edit", middleware.AdminRequired(forum.AddEdit())) - mux.Handle("/inner-circle/remove", middleware.AdminRequired(account.RemoveCircle())) + mux.Handle("/admin", middleware.AdminRequired("", admin.Dashboard())) + mux.Handle("/admin/scopes", middleware.AdminRequired("", admin.Scopes())) + mux.Handle("/admin/photo/certification", middleware.AdminRequired("", photo.AdminCertification())) + mux.Handle("/admin/feedback", middleware.AdminRequired("", admin.Feedback())) + mux.Handle("/admin/user-action", middleware.AdminRequired("", admin.UserActions())) + mux.Handle("/forum/admin", middleware.AdminRequired(config.ScopeForumAdmin, forum.Manage())) + mux.Handle("/forum/admin/edit", middleware.AdminRequired(config.ScopeForumAdmin, forum.AddEdit())) + mux.Handle("/inner-circle/remove", middleware.AdminRequired(config.ScopeCircleModerator, account.RemoveCircle())) // JSON API endpoints. mux.HandleFunc("/v1/version", api.Version()) diff --git a/web/templates/admin/dashboard.html b/web/templates/admin/dashboard.html index ed98b48..822929e 100644 --- a/web/templates/admin/dashboard.html +++ b/web/templates/admin/dashboard.html @@ -142,6 +142,12 @@ Gallery: Admin View +
  • + + + Admin Permissions Management + +
  • @@ -149,4 +155,4 @@ -{{end}} \ No newline at end of file +{{end}} diff --git a/web/templates/admin/scopes.html b/web/templates/admin/scopes.html new file mode 100644 index 0000000..c4d2fae --- /dev/null +++ b/web/templates/admin/scopes.html @@ -0,0 +1,239 @@ +{{define "title"}}Admin - Scopes & Permissions{{end}} +{{define "content"}} +{{$Root := .}} +
    +
    +
    +
    +

    + Scopes & Permissions +

    +
    +
    +
    + + {{if or (eq .Intent "new") (eq .Intent "edit")}} + +
    +
    +

    + {{if .EditGroupID}}Edit{{else}}New{{end}} Admin Group +

    +
    + {{InputCSRF}} + + +
    + + +
    + +
    + + +

    + Enter scopes one per line. Wildcards (*) may be used, as in social.moderator.* +

    +
    + +
    + + {{range .AdminUsers}} +
    + +
    + {{end}} +

    + Select the admin user(s) who should be a part of this group. +

    +
    + +
    + + + {{if .EditGroupID}} + + {{end}} + + + Cancel + +
    +
    +
    + +
    +
    +
    +

    + Admin Scopes +

    +
    +
    +

    + The complete listing of available admin scopes on the website + are as follows: +

    + +
      + {{range .AdminScopes}} +
    • {{.}}
    • + {{end}} +
    +
    +
    +
    +
    + {{else}} +
    + +

    + This page allows you to manage the nuanced permissions for admin accounts on {{PrettyTitle}}. + Permissions are managed by placing admins into groups, and in turn, assigning one or more + scopes to each group. +

    + + + {{if .NeedSuperuserInit}} +
    +

    + The Superusers group was not found! Click the button below + to initialize the Superusers group and add yourself to it automatically. +

    + +

    +

    + {{InputCSRF}} + +
    +

    +
    + {{end}} + +
    + +

    Admin Users

    + +

    + Found {{len .AdminUsers}} admin user account{{Pluralize (len .AdminUsers)}}. +

    + + + + + + + + + + + + + {{range .AdminUsers}} + + + + + + + + {{end}} + +
    IDUsernameEmailAdmin GroupsScopes
    {{.ID}}{{.Username}}{{.Email}} +
      + {{range .ListAdminGroups}} +
    • {{.}}
    • + {{end}} +
    +
    +
      + {{range .ListAdminScopes}} +
    • {{.}}
    • + {{end}} +
    +
    + +
    + +

    Admin Groups

    + +

    + Found {{len .AdminGroups}} admin group{{Pluralize (len .AdminUsers)}}. + Create new group. +

    + + + + + + + + + + + + + {{range .AdminGroups}} + + + + + + + + {{end}} + +
    IDNameScopesUsersEdit
    {{.ID}}{{.Name}} +
      + {{range .Scopes}} +
    • {{.Scope}}
    • + {{end}} +
    +
    + + + Edit +
    + +
    + {{end}} + +
    +{{end}} diff --git a/web/templates/photo/gallery.html b/web/templates/photo/gallery.html index 737647f..25c1cfe 100644 --- a/web/templates/photo/gallery.html +++ b/web/templates/photo/gallery.html @@ -258,7 +258,7 @@ - {{if .CurrentUser.IsAdmin}} + {{if .CurrentUser.HasAdminScope "social.moderator.photo"}}