Files
mailgoserver/internal/webui/admins.go
T

267 lines
8.2 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, "current_user_id": userFromContext(r).ID})
}
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
}
// resetAdminMFA clears a target admin's TOTP and passkeys — e.g. after a lost device
// — so they can sign back in without a second factor (or under enforce_admin_mfa,
// re-enroll from /account on their next login) without needing database access.
func (a *App) resetAdminMFA(w http.ResponseWriter, r *http.Request) {
target, ok := a.adminWithManageAccess(w, r)
if !ok {
return
}
if err := a.DB.ResetAdminMFA(target.ID); err != nil {
setFlash(w, "error", "Error resetting MFA")
} else {
setFlash(w, "success", "MFA reset for "+target.Username)
}
http.Redirect(w, r, Prefix+"/admins", http.StatusFound)
}
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)
}