first commit
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
var errNotOwned = errors.New("domain not in the requesting admin's scope")
|
||||
|
||||
// manageableAdmins mirrors the delegation rule: a global admin manages everyone; a
|
||||
// scoped admin manages any other scoped admin whose entire domain assignment is a
|
||||
// subset of their own (not just admins they personally created) — see the approved
|
||||
// design in the conversation this shipped from.
|
||||
func (a *App) manageableAdmins(r *http.Request) ([]db.AdminUser, error) {
|
||||
user := userFromContext(r)
|
||||
if user.IsGlobalAdmin {
|
||||
return a.DB.ListAllAdminUsers()
|
||||
}
|
||||
scope := scopeFromContext(r)
|
||||
scoped, err := a.DB.ListScopedAdminUsers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []db.AdminUser
|
||||
for _, other := range scoped {
|
||||
if other.ID == user.ID {
|
||||
continue
|
||||
}
|
||||
theirDomains, err := a.DB.AccessibleDomainIDs(other.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isDomainSubset(theirDomains, scope) {
|
||||
out = append(out, other)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func isDomainSubset(ids []int64, scope accessScope) bool {
|
||||
for _, id := range ids {
|
||||
if !scope.Allowed(id) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// canManageAdmin re-checks a specific target admin against the current admin's
|
||||
// delegation rights — used by the mutating routes so they don't just trust whatever
|
||||
// the list page happened to render.
|
||||
func (a *App) canManageAdmin(r *http.Request, target *db.AdminUser) (bool, error) {
|
||||
user := userFromContext(r)
|
||||
if target.ID == user.ID {
|
||||
return false, nil
|
||||
}
|
||||
if user.IsGlobalAdmin {
|
||||
return true, nil
|
||||
}
|
||||
if target.IsGlobalAdmin {
|
||||
return false, nil
|
||||
}
|
||||
theirDomains, err := a.DB.AccessibleDomainIDs(target.ID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return isDomainSubset(theirDomains, scopeFromContext(r)), nil
|
||||
}
|
||||
|
||||
func (a *App) adminsList(w http.ResponseWriter, r *http.Request) {
|
||||
admins, err := a.manageableAdmins(r)
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Error loading admins")
|
||||
}
|
||||
var rows []M
|
||||
for _, u := range admins {
|
||||
domainIDs, _ := a.DB.AccessibleDomainIDs(u.ID)
|
||||
var domainNames []string
|
||||
for _, id := range domainIDs {
|
||||
if dom, _ := a.DB.GetDomainByID(id); dom != nil {
|
||||
domainNames = append(domainNames, dom.DomainName)
|
||||
}
|
||||
}
|
||||
rows = append(rows, M{"user": u, "domain_names": domainNames})
|
||||
}
|
||||
a.render(w, r, "admins.html", M{"active": "admins", "rows": rows})
|
||||
}
|
||||
|
||||
func (a *App) addAdminForm(w http.ResponseWriter, r *http.Request) {
|
||||
domains, _ := a.accessibleDomains(r)
|
||||
a.render(w, r, "add_admin.html", M{"active": "admins", "domains": domains, "can_grant_global": userFromContext(r).IsGlobalAdmin})
|
||||
}
|
||||
|
||||
// addAdmin mirrors the delegation flow: creates a new scoped admin (or, for a global
|
||||
// admin, optionally a new global admin), forced to change their password on first
|
||||
// login exactly like the seeded default account.
|
||||
func (a *App) addAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFromContext(r)
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
makeGlobal := user.IsGlobalAdmin && r.FormValue("is_global_admin") == "on"
|
||||
|
||||
if username == "" || !isStrongPassword(password) {
|
||||
setFlash(w, "error", "Username is required and password must be at least 10 characters with a letter, a number, and a symbol")
|
||||
http.Redirect(w, r, Prefix+"/admins/add", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if existing, _ := a.DB.GetAdminUserByUsername(username); existing != nil {
|
||||
setFlash(w, "error", "That username is already taken")
|
||||
http.Redirect(w, r, Prefix+"/admins/add", http.StatusFound)
|
||||
return
|
||||
}
|
||||
hash, err := db.HashPassword(password)
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Something went wrong")
|
||||
http.Redirect(w, r, Prefix+"/admins/add", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
if makeGlobal {
|
||||
if _, err := a.DB.CreateAdminUser(username, hash, true); err != nil {
|
||||
setFlash(w, "error", "Error creating admin")
|
||||
http.Redirect(w, r, Prefix+"/admins/add", http.StatusFound)
|
||||
return
|
||||
}
|
||||
setFlash(w, "success", "Global admin created")
|
||||
http.Redirect(w, r, Prefix+"/admins", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
domainIDs, err := a.parseOwnedDomainIDs(r)
|
||||
if err != nil {
|
||||
setFlash(w, "error", "You can only assign domains you manage yourself")
|
||||
http.Redirect(w, r, Prefix+"/admins/add", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if _, err := a.DB.CreateScopedAdminUser(username, hash, user.ID, domainIDs); err != nil {
|
||||
setFlash(w, "error", "Error creating admin")
|
||||
http.Redirect(w, r, Prefix+"/admins/add", http.StatusFound)
|
||||
return
|
||||
}
|
||||
setFlash(w, "success", "Admin created and given access to the selected domains")
|
||||
http.Redirect(w, r, Prefix+"/admins", http.StatusFound)
|
||||
}
|
||||
|
||||
// parseOwnedDomainIDs reads the "domain_ids" checkbox list from the form and rejects
|
||||
// the request outright if any of them fall outside the current admin's own scope —
|
||||
// the actual enforcement point for "can only delegate domains you have yourself".
|
||||
func (a *App) parseOwnedDomainIDs(r *http.Request) ([]int64, error) {
|
||||
scope := scopeFromContext(r)
|
||||
var ids []int64
|
||||
for _, v := range r.Form["domain_ids"] {
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !scope.Allowed(id) {
|
||||
return nil, errNotOwned
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (a *App) editAdminDomainsForm(w http.ResponseWriter, r *http.Request) {
|
||||
target, ok := a.adminWithManageAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
domains, _ := a.accessibleDomains(r)
|
||||
assigned, _ := a.DB.AccessibleDomainIDs(target.ID)
|
||||
assignedSet := make(map[int64]bool, len(assigned))
|
||||
for _, id := range assigned {
|
||||
assignedSet[id] = true
|
||||
}
|
||||
a.render(w, r, "edit_admin.html", M{"active": "admins", "target": target, "domains": domains, "assigned": assignedSet})
|
||||
}
|
||||
|
||||
func (a *App) editAdminDomains(w http.ResponseWriter, r *http.Request) {
|
||||
target, ok := a.adminWithManageAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
setFlash(w, "error", "Invalid form data")
|
||||
http.Redirect(w, r, Prefix+"/admins", http.StatusFound)
|
||||
return
|
||||
}
|
||||
domainIDs, err := a.parseOwnedDomainIDs(r)
|
||||
if err != nil {
|
||||
setFlash(w, "error", "You can only assign domains you manage yourself")
|
||||
http.Redirect(w, r, Prefix+"/admins/"+strconv.FormatInt(target.ID, 10)+"/edit", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := a.DB.SetAdminDomainAccess(target.ID, domainIDs); err != nil {
|
||||
setFlash(w, "error", "Error updating domain access")
|
||||
} else {
|
||||
setFlash(w, "success", "Domain access updated")
|
||||
}
|
||||
http.Redirect(w, r, Prefix+"/admins", http.StatusFound)
|
||||
}
|
||||
|
||||
// adminWithManageAccess fetches the target admin by path ID and re-validates the
|
||||
// delegation rule server-side (never trust that the list page's filtering was the
|
||||
// only gate).
|
||||
func (a *App) adminWithManageAccess(w http.ResponseWriter, r *http.Request) (*db.AdminUser, bool) {
|
||||
target, err := a.DB.GetAdminUserByID(pathID(r))
|
||||
if err != nil || target == nil {
|
||||
http.NotFound(w, r)
|
||||
return nil, false
|
||||
}
|
||||
allowed, err := a.canManageAdmin(r, target)
|
||||
if err != nil || !allowed {
|
||||
http.NotFound(w, r)
|
||||
return nil, false
|
||||
}
|
||||
return target, true
|
||||
}
|
||||
|
||||
func (a *App) removeAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
target, ok := a.adminWithManageAccess(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if target.IsGlobalAdmin {
|
||||
if all, err := a.DB.ListAllAdminUsers(); err == nil {
|
||||
remaining := 0
|
||||
for _, u := range all {
|
||||
if u.IsGlobalAdmin {
|
||||
remaining++
|
||||
}
|
||||
}
|
||||
if remaining <= 1 {
|
||||
setFlash(w, "error", "Can't remove the last global admin")
|
||||
http.Redirect(w, r, Prefix+"/admins", http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := a.DB.DeleteAdminUser(target.ID); err != nil {
|
||||
setFlash(w, "error", "Error removing admin")
|
||||
} else {
|
||||
setFlash(w, "success", "Admin removed")
|
||||
}
|
||||
http.Redirect(w, r, Prefix+"/admins", http.StatusFound)
|
||||
}
|
||||
Reference in New Issue
Block a user