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
This commit is contained in:
Noah Petherbridge 2023-08-01 20:39:48 -07:00
parent 01317a7ff8
commit 47aaf15078
22 changed files with 967 additions and 23 deletions

View File

@ -18,6 +18,10 @@ setup:
build: build:
go build $(LDFLAGS) -o nonshy cmd/nonshy/main.go go build $(LDFLAGS) -o nonshy cmd/nonshy/main.go
.PHONY: test
test:
go test ./...
.PHONY: run .PHONY: run
run: run:
go run cmd/nonshy/main.go web --debug go run cmd/nonshy/main.go web --debug

View File

@ -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,
}
}

View File

@ -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),
)
}
}

View File

@ -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
}
})
}

View File

@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/models/deletion" "code.nonshy.com/nonshy/website/pkg/models/deletion"
"code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/session"
@ -47,6 +48,13 @@ func UserActions() http.HandlerFunc {
switch intent { switch intent {
case "impersonate": 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 confirm {
if err := session.ImpersonateUser(w, r, user, currentUser, reason); err != nil { if err := session.ImpersonateUser(w, r, user, currentUser, reason); err != nil {
session.FlashError(w, r, "Failed to impersonate user: %s", err) session.FlashError(w, r, "Failed to impersonate user: %s", err)
@ -57,6 +65,13 @@ func UserActions() http.HandlerFunc {
} }
} }
case "ban": 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 { if confirm {
status := r.PostFormValue("status") status := r.PostFormValue("status")
if status == "active" { if status == "active" {
@ -71,6 +86,13 @@ func UserActions() http.HandlerFunc {
return return
} }
case "promote": 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 { if confirm {
action := r.PostFormValue("action") action := r.PostFormValue("action")
user.IsAdmin = action == "promote" user.IsAdmin = action == "promote"
@ -80,6 +102,13 @@ func UserActions() http.HandlerFunc {
return return
} }
case "delete": 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 confirm {
if err := deletion.DeleteUser(user); err != nil { if err := deletion.DeleteUser(user); err != nil {
session.FlashError(w, r, "Failed when deleting the user: %s", err) session.FlashError(w, r, "Failed when deleting the user: %s", err)

View File

@ -82,7 +82,7 @@ func Landing() http.HandlerFunc {
// Create the JWT claims. // Create the JWT claims.
claims := Claims{ claims := Claims{
IsAdmin: currentUser.IsAdmin, IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator),
Avatar: avatar, Avatar: avatar,
ProfileURL: "/u/" + currentUser.Username, ProfileURL: "/u/" + currentUser.Username,
Nickname: currentUser.NameOrUsername(), Nickname: currentUser.NameOrUsername(),

View File

@ -115,7 +115,7 @@ func NewPost() http.HandlerFunc {
comment = found comment = found
// Verify that it is indeed OUR comment. // 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) templates.ForbiddenPage(w, r)
return return
} }

View File

@ -180,8 +180,33 @@ func AdminCertification() http.HandlerFunc {
session.FlashError(w, r, "Couldn't get CurrentUser: %s", err) 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) // Short circuit the GET view for username/email search (exact match)
if username := r.FormValue("username"); username != "" { 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) user, err := models.FindUser(username)
if err != nil { if err != nil {
session.FlashError(w, r, "Username or email '%s' not found.", username) session.FlashError(w, r, "Username or email '%s' not found.", username)
@ -210,6 +235,13 @@ func AdminCertification() http.HandlerFunc {
// Making a verdict? // Making a verdict?
if r.Method == http.MethodPost { 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 ( var (
comment = r.PostFormValue("comment") comment = r.PostFormValue("comment")
verdict = r.PostFormValue("verdict") verdict = r.PostFormValue("verdict")

View File

@ -57,6 +57,11 @@ func SiteGallery() http.HandlerFunc {
// Is the current viewer shy? // Is the current viewer shy?
var isShy = currentUser.IsShy() 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. // Get the page of photos.
pager := &models.Pagination{ pager := &models.Pagination{
Page: 1, Page: 1,

View File

@ -95,7 +95,7 @@ func UserPhotos() http.HandlerFunc {
// What set of visibilities to query? // What set of visibilities to query?
visibility := []models.PhotoVisibility{models.PhotoPublic} visibility := []models.PhotoVisibility{models.PhotoPublic}
if isOwnPhotos || isGrantee || currentUser.IsAdmin { if isOwnPhotos || isGrantee || currentUser.HasAdminScope(config.ScopePhotoModerator) {
visibility = append(visibility, models.PhotoFriends, models.PhotoPrivate) visibility = append(visibility, models.PhotoFriends, models.PhotoPrivate)
} else if models.AreFriends(user.ID, currentUser.ID) { } else if models.AreFriends(user.ID, currentUser.ID) {
visibility = append(visibility, models.PhotoFriends) visibility = append(visibility, models.PhotoFriends)

View File

@ -60,7 +60,7 @@ func LoginRequired(handler http.Handler) http.Handler {
} }
// AdminRequired middleware. // 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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// User must be logged in. // User must be logged in.
@ -77,12 +77,22 @@ func AdminRequired(handler http.Handler) http.Handler {
// Admin required. // Admin required.
if !currentUser.IsAdmin { if !currentUser.IsAdmin {
log.Error("AdminRequired: %s", err)
errhandler := templates.MakeErrorPage("Admin Required", "You do not have permission for this page.", http.StatusForbidden) errhandler := templates.MakeErrorPage("Admin Required", "You do not have permission for this page.", http.StatusForbidden)
errhandler.ServeHTTP(w, r.WithContext(ctx)) errhandler.ServeHTTP(w, r.WithContext(ctx))
return 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)) handler.ServeHTTP(w, r.WithContext(ctx))
}) })
} }

290
pkg/models/admin_scopes.go Normal file
View File

@ -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
}

View File

@ -149,7 +149,7 @@ func FriendIDsInCircle(userId uint64) []uint64 {
).Scan(&userIDs) ).Scan(&userIDs)
if err.Error != nil { 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 return userIDs
@ -174,7 +174,7 @@ func FriendIDsInCircleAreExplicit(userId uint64) []uint64 {
).Scan(&userIDs) ).Scan(&userIDs)
if err.Error != nil { 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 return userIDs

View File

@ -26,4 +26,6 @@ func AutoMigrate() {
DB.AutoMigrate(&CommentPhoto{}) DB.AutoMigrate(&CommentPhoto{})
DB.AutoMigrate(&Poll{}) DB.AutoMigrate(&Poll{})
DB.AutoMigrate(&PollVote{}) DB.AutoMigrate(&PollVote{})
DB.AutoMigrate(&AdminGroup{})
DB.AutoMigrate(&AdminScope{})
} }

View File

@ -5,6 +5,7 @@ import (
"strings" "strings"
"time" "time"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
"gorm.io/gorm" "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). // 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. // Include ourself in our friend IDs.
friendIDs = append(friendIDs, userID) friendIDs = append(friendIDs, userID)

View File

@ -269,7 +269,7 @@ func MapThreadStatistics(threads []*Thread) ThreadStatsMap {
).Group("table_id").Scan(&groups) ).Group("table_id").Scan(&groups)
if err != nil { if err != nil {
log.Error("MapThreadStatistics: SQL error: %s", err) log.Error("MapThreadStatistics: SQL error: %s", err.Error)
} }
// Map the results in. // Map the results in.

View File

@ -36,7 +36,8 @@ type User struct {
// Relational tables. // Relational tables.
ProfileField []ProfileField ProfileField []ProfileField
ProfilePhotoID *uint64 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. // Current user's relationship to this user -- not stored in DB.
UserRelationship UserRelationship `gorm:"-"` UserRelationship UserRelationship `gorm:"-"`
@ -62,7 +63,7 @@ var UserVisibilityOptions = []UserVisibility{
// Preload related tables for the user (classmethod). // Preload related tables for the user (classmethod).
func (u *User) Preload() *gorm.DB { func (u *User) Preload() *gorm.DB {
return DB.Preload("ProfileField").Preload("ProfilePhoto") return DB.Preload("ProfileField").Preload("ProfilePhoto").Preload("AdminGroups.Scopes")
} }
// UserStatus options. // UserStatus options.
@ -338,6 +339,47 @@ func (um UserMap) Get(id uint64) *User {
return nil 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. // NameOrUsername returns the name (if not null or empty) or the username.
func (u *User) NameOrUsername() string { func (u *User) NameOrUsername() string {
if u.Name != nil && len(*u.Name) > 0 { if u.Name != nil && len(*u.Name) > 0 {

View File

@ -3,6 +3,7 @@ package models
import ( import (
"errors" "errors"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log" "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). // 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 { 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. // 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 = ?", "user_id = ? AND visibility = ?",
u.ID, PhotoInnerCircle, u.ID, PhotoInnerCircle,
).Update("visibility", PhotoFriends); err != nil { ).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. // Revoke any historic notification about the circle.

View File

@ -80,13 +80,14 @@ func New() http.Handler {
mux.Handle("/poll/vote", middleware.CertRequired(poll.Vote())) mux.Handle("/poll/vote", middleware.CertRequired(poll.Vote()))
// Admin endpoints. // Admin endpoints.
mux.Handle("/admin", middleware.AdminRequired(admin.Dashboard())) mux.Handle("/admin", middleware.AdminRequired("", admin.Dashboard()))
mux.Handle("/admin/photo/certification", middleware.AdminRequired(photo.AdminCertification())) mux.Handle("/admin/scopes", middleware.AdminRequired("", admin.Scopes()))
mux.Handle("/admin/feedback", middleware.AdminRequired(admin.Feedback())) mux.Handle("/admin/photo/certification", middleware.AdminRequired("", photo.AdminCertification()))
mux.Handle("/admin/user-action", middleware.AdminRequired(admin.UserActions())) mux.Handle("/admin/feedback", middleware.AdminRequired("", admin.Feedback()))
mux.Handle("/forum/admin", middleware.AdminRequired(forum.Manage())) mux.Handle("/admin/user-action", middleware.AdminRequired("", admin.UserActions()))
mux.Handle("/forum/admin/edit", middleware.AdminRequired(forum.AddEdit())) mux.Handle("/forum/admin", middleware.AdminRequired(config.ScopeForumAdmin, forum.Manage()))
mux.Handle("/inner-circle/remove", middleware.AdminRequired(account.RemoveCircle())) 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. // JSON API endpoints.
mux.HandleFunc("/v1/version", api.Version()) mux.HandleFunc("/v1/version", api.Version())

View File

@ -142,6 +142,12 @@
Gallery: Admin View Gallery: Admin View
</a> </a>
</li> </li>
<li>
<a href="/admin/scopes">
<i class="fa fa-gavel mr-2"></i>
Admin Permissions Management
</a>
</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -0,0 +1,239 @@
{{define "title"}}Admin - Scopes &amp; Permissions{{end}}
{{define "content"}}
{{$Root := .}}
<div class="container">
<section class="hero is-danger is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">
Scopes &amp; Permissions
</h1>
</div>
</div>
</section>
{{if or (eq .Intent "new") (eq .Intent "edit")}}
<!-- Create New/Edit a Group -->
<div class="columns mt-2">
<div class="column is-three-quarters">
<h1 class="title">
{{if .EditGroupID}}Edit{{else}}New{{end}} Admin Group
</h1>
<form action="{{.Request.URL.Path}}" method="POST">
{{InputCSRF}}
<input type="hidden" name="id" value="{{.EditGroupID}}">
<div class="field">
<label class="label" for="name">Group Name</label>
<input type="text" class="input"
name="name"
id="name"
autocomplete="off"
value="{{.EditGroup.Name}}"
placeholder="Forum Moderators"
{{if .EditGroupID}}readonly{{end}}>
</div>
<div class="field">
<label class="label" for="scopes">Scopes</label>
<textarea class="textarea"
cols="80" rows="4"
name="scopes"
id="scopes"
placeholder="one.scope.per.line">{{.EditGroup.ScopesString}}</textarea>
<p class="help">
Enter scopes one per line. Wildcards (*) may be used, as in <code>social.moderator.*</code>
</p>
</div>
<div class="field">
<label class="label">Admin Users</label>
{{range .AdminUsers}}
<div>
<label class="checkbox">
<input type="checkbox"
name="username"
value="{{.Username}}"
{{if $Root.EditGroup.HasAdmin .Username}}checked{{end}}>
{{.Username}}
</label>
</div>
{{end}}
<p class="help">
Select the admin user(s) who should be a part of this group.
</p>
</div>
<div class="field">
<button type="submit" class="button is-primary"
name="intent"
value="save">
Save Changes
</button>
{{if .EditGroupID}}
<button type="submit" class="button is-danger"
name="intent"
value="delete"
onclick="return window.confirm('Are you sure you want to delete this group?')">
<i class="fa fa-trash mr-1"></i>
Delete
</button>
{{end}}
<a href="{{.Request.URL.Path}}" class="button is-secondary">
Cancel
</a>
</div>
</form>
</div>
<div class="column is-one-quarter">
<div class="card">
<div class="card-header has-background-info">
<p class="card-header-title has-text-light">
Admin Scopes
</p>
</div>
<div class="card-content">
<p class="block">
The complete listing of available admin scopes on the website
are as follows:
</p>
<ul>
{{range .AdminScopes}}
<li>{{.}}</li>
{{end}}
</ul>
</div>
</div>
</div>
</div>
{{else}}
<div class="p-4">
<p class="block">
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.
</p>
<!-- Superusers group init? -->
{{if .NeedSuperuserInit}}
<div class="notification is-warning content">
<p>
The <strong>Superusers</strong> group was not found! Click the button below
to initialize the Superusers group and add yourself to it automatically.
</p>
<p>
<form action="{{.Request.URL.Path}}" method="POST">
{{InputCSRF}}
<button type="submit"
name="intent"
value="init-superusers"
class="button is-danger">
Initialize the Superusers Group
</button>
</form>
</p>
</div>
{{end}}
<hr>
<h1 class="title">Admin Users</h1>
<p class="block">
Found <strong>{{len .AdminUsers}}</strong> admin user account{{Pluralize (len .AdminUsers)}}.
</p>
<table class="table is-striped is-fullwidth">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Email</th>
<th>Admin Groups</th>
<th>Scopes</th>
</tr>
</thead>
<tbody>
{{range .AdminUsers}}
<tr>
<td>{{.ID}}</td>
<td><a href="/u/{{.Username}}">{{.Username}}</a></td>
<td><a href="mailto:{{.Email}}">{{.Email}}</a></td>
<td>
<ul>
{{range .ListAdminGroups}}
<li>{{.}}</li>
{{end}}
</ul>
</td>
<td>
<ul>
{{range .ListAdminScopes}}
<li>{{.}}</li>
{{end}}
</ul>
</td>
</tr>
{{end}}
</tbody>
</table>
<hr>
<h1 class="title">Admin Groups</h1>
<p class="block">
Found <strong>{{len .AdminGroups}}</strong> admin group{{Pluralize (len .AdminUsers)}}.
<a href="{{.Request.URL.Path}}?intent=new">Create new group.</a>
</p>
<table class="table is-striped is-fullwidth">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Scopes</th>
<th>Users</th>
<th>Edit</th>
</tr>
</thead>
<tbody>
{{range .AdminGroups}}
<tr>
<td>{{.ID}}</td>
<td>{{.Name}}</td>
<td>
<ul>
{{range .Scopes}}
<li>{{.Scope}}</li>
{{end}}
</ul>
</td>
<td>
<ul>
{{range .Users}}
<li>
<a href="/u/{{.Username}}">{{.Username}}</a>
</li>
{{end}}
</ul>
</td>
<td>
<a href="{{$Root.Request.URL.Path}}?intent=edit&id={{.ID}}">Edit</a>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
</div>
{{end}}

View File

@ -258,7 +258,7 @@
</div> </div>
</div> </div>
{{if .CurrentUser.IsAdmin}} {{if .CurrentUser.HasAdminScope "social.moderator.photo"}}
<div class="column"> <div class="column">
<div class="field"> <div class="field">
<label class="label has-text-danger" for="admin_view">Admin view:</label> <label class="label has-text-danger" for="admin_view">Admin view:</label>