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 { _ = a.DB.LogAuthAttempt("admin_mfa", target.Username, a.requestIP(r), true, "MFA reset by admin "+userFromContext(r).Username) 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) }