251 lines
7.6 KiB
Go
251 lines
7.6 KiB
Go
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)
|
|
}
|