feat(panel): add domain-admin role with per-domain authorization
test / test (push) Has been cancelled
test / test (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type userFormView struct {
|
||||
FormErr string
|
||||
FormUsername string
|
||||
FormRole string
|
||||
FormDomains map[int64]bool
|
||||
FormPassword string
|
||||
}
|
||||
|
||||
// HandleUsers lists panel users (global only).
|
||||
func (h *Handlers) HandleUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
rows, err := h.store.ListUserRows()
|
||||
if err != nil {
|
||||
logf("panel: list users: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := h.pageBase(r)
|
||||
data["Title"] = "SelfPost — users"
|
||||
data["Active"] = "users"
|
||||
data["Users"] = rows
|
||||
data["Flash"] = usersFlash(r)
|
||||
h.view.Render(w, http.StatusOK, "users", data)
|
||||
}
|
||||
|
||||
func usersFlash(r *http.Request) string {
|
||||
switch r.URL.Query().Get("done") {
|
||||
case "created":
|
||||
return "User created."
|
||||
case "updated":
|
||||
return "User updated."
|
||||
case "deleted":
|
||||
return "User deleted."
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUserNew creates a panel user (global only).
|
||||
func (h *Handlers) HandleUserNew(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.renderUserForm(w, r, http.StatusOK, 0, userFormView{FormRole: string(store.RoleDomainAdmin)})
|
||||
case http.MethodPost:
|
||||
h.submitUserCreate(w, r)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUserEdit edits or deletes a panel user (global only).
|
||||
func (h *Handlers) HandleUserEdit(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
uid, ok := parseUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
u, err := h.store.GetUser(uid)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrUserNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
logf("panel: get user %d: %v", uid, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
selected := make(map[int64]bool, len(u.DomainIDs))
|
||||
for _, id := range u.DomainIDs {
|
||||
selected[id] = true
|
||||
}
|
||||
h.renderUserForm(w, r, http.StatusOK, u.ID, userFormView{
|
||||
FormUsername: u.Username,
|
||||
FormRole: string(u.Role),
|
||||
FormDomains: selected,
|
||||
})
|
||||
case http.MethodPost:
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormRole: string(u.Role)})
|
||||
return
|
||||
}
|
||||
if r.PostFormValue("action") == "delete" {
|
||||
h.submitUserDelete(w, r, u)
|
||||
return
|
||||
}
|
||||
h.submitUserUpdate(w, r, u)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) renderUserForm(w http.ResponseWriter, r *http.Request, status int, userID int64, view userFormView) {
|
||||
domains, err := h.store.ListDomains()
|
||||
if err != nil {
|
||||
logf("panel: user form: list domains: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := h.pageBase(r)
|
||||
data["Title"] = "SelfPost — user"
|
||||
data["Active"] = "users"
|
||||
data["UserID"] = userID
|
||||
data["Domains"] = domains
|
||||
data["Error"] = view.FormErr
|
||||
data["FormUsername"] = view.FormUsername
|
||||
data["FormRole"] = view.FormRole
|
||||
data["FormDomains"] = view.FormDomains
|
||||
data["FormPassword"] = view.FormPassword
|
||||
data["IsEdit"] = userID != 0
|
||||
h.view.Render(w, status, "user_form", data)
|
||||
}
|
||||
|
||||
func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Invalid form submission."})
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
role := store.Role(r.PostFormValue("role"))
|
||||
domainIDs := parseDomainIDs(r)
|
||||
|
||||
if err := validate.Username(username); err != nil {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
|
||||
return
|
||||
}
|
||||
if err := validate.AdminPassword(password); err != nil {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
|
||||
return
|
||||
}
|
||||
if role != store.RoleGlobal && role != store.RoleDomainAdmin {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
|
||||
return
|
||||
}
|
||||
if role == store.RoleDomainAdmin && len(domainIDs) == 0 {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
logf("panel: create user hash: %v", err)
|
||||
h.renderUserForm(w, r, http.StatusInternalServerError, 0, userFormView{FormErr: "Internal error. Please try again."})
|
||||
return
|
||||
}
|
||||
if _, err := h.store.CreateUser(username, string(hash), role, domainIDs); err != nil {
|
||||
if errors.Is(err, store.ErrUserExists) {
|
||||
h.renderUserForm(w, r, http.StatusConflict, 0, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
|
||||
return
|
||||
}
|
||||
logf("panel: create user: %v", err)
|
||||
h.renderUserForm(w, r, http.StatusInternalServerError, 0, userFormView{FormErr: "Could not create user. Please check the logs."})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/users?done=created", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u store.User) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormRole: string(u.Role)})
|
||||
return
|
||||
}
|
||||
p, ok := h.principal(r)
|
||||
if !ok {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
role := store.Role(r.PostFormValue("role"))
|
||||
domainIDs := parseDomainIDs(r)
|
||||
selected := domainIDSetFromForm(r)
|
||||
|
||||
if username == "" {
|
||||
username = u.Username
|
||||
}
|
||||
if err := validate.Username(username); err != nil {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
if role != store.RoleGlobal && role != store.RoleDomainAdmin {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
if role == store.RoleDomainAdmin && len(domainIDs) == 0 {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
|
||||
if u.Role == store.RoleGlobal && role == store.RoleDomainAdmin {
|
||||
n, err := h.store.CountGlobalUsers()
|
||||
if err != nil || n <= 1 {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Cannot demote the last global administrator.", FormUsername: username, FormRole: string(u.Role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if u.ID == p.ID && u.Role == store.RoleGlobal && role == store.RoleDomainAdmin {
|
||||
n, err := h.store.CountGlobalUsers()
|
||||
if err != nil || n <= 1 {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "You cannot demote yourself without another global administrator.", FormUsername: username, FormRole: string(u.Role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
hash := u.PasswordHash
|
||||
if password != "" {
|
||||
if err := validate.AdminPassword(password); err != nil {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
logf("panel: update user hash: %v", err)
|
||||
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Internal error. Please try again.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
hash = string(newHash)
|
||||
}
|
||||
|
||||
if err := h.store.UpdateUser(u.ID, username, hash, u.DMARCReportEmail); err != nil {
|
||||
if errors.Is(err, store.ErrUserExists) {
|
||||
h.renderUserForm(w, r, http.StatusConflict, u.ID, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
logf("panel: update user: %v", err)
|
||||
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save user. Please check the logs.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
|
||||
if role != u.Role {
|
||||
if err := h.store.SetUserRole(u.ID, role); err != nil {
|
||||
logf("panel: set user role: %v", err)
|
||||
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not update role.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
if role == store.RoleGlobal {
|
||||
if err := h.store.ClearUserDomains(u.ID); err != nil {
|
||||
logf("panel: clear user domains: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if role == store.RoleDomainAdmin {
|
||||
if err := h.store.SetUserDomains(u.ID, domainIDs); err != nil {
|
||||
logf("panel: set user domains: %v", err)
|
||||
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save domain assignments.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/users?done=updated", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handlers) submitUserDelete(w http.ResponseWriter, r *http.Request, u store.User) {
|
||||
p, ok := h.principal(r)
|
||||
if !ok {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if u.ID == p.ID {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "You cannot delete your own account while signed in.", FormUsername: u.Username, FormRole: string(u.Role)})
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteUser(u.ID); err != nil {
|
||||
if errors.Is(err, store.ErrLastGlobal) {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Cannot delete the last global administrator.", FormUsername: u.Username, FormRole: string(u.Role)})
|
||||
return
|
||||
}
|
||||
logf("panel: delete user %d: %v", u.ID, err)
|
||||
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not delete user.", FormUsername: u.Username, FormRole: string(u.Role)})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/users?done=deleted", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func parseUserID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
id, err := strconv.ParseInt(r.PathValue("uid"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
http.NotFound(w, r)
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func parseDomainIDs(r *http.Request) []int64 {
|
||||
var ids []int64
|
||||
for _, v := range r.PostForm["domain_ids"] {
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err == nil && id > 0 {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func domainIDSetFromForm(r *http.Request) map[int64]bool {
|
||||
m := make(map[int64]bool)
|
||||
for _, id := range parseDomainIDs(r) {
|
||||
m[id] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
Reference in New Issue
Block a user