Dashboard and API, accounts (organizations, roles, invitations, 2FA, OIDC single sign-on, API keys, revocable sessions), an encrypted SQLite database, resource logins, alerts and /metrics, mail egress management with DKIM keys, and the .env loader. Split out of the original single repository; depends on Reef for the shared types. The end-to-end tests that run Hammerhead, Blacktip and Bamboo together live in e2e/. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
643 lines
17 KiB
Go
643 lines
17 KiB
Go
package hammerhead
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.veillax.com/Veillax/reef/model"
|
|
)
|
|
|
|
// ---------- organizations ----------
|
|
|
|
func (s *Server) createOrg(w http.ResponseWriter, r *http.Request, p *principal) {
|
|
var in struct {
|
|
Name string
|
|
AllowLocal bool
|
|
}
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
in.Name = strings.TrimSpace(in.Name)
|
|
if in.Name == "" {
|
|
fail(w, 400, errors.New("name is required"))
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
org, adminRole := s.newOrgLocked(in.Name, in.AllowLocal)
|
|
s.st.Members = append(s.st.Members, &Member{UserID: p.User.ID, OrgID: org.ID, RoleID: adminRole.ID})
|
|
s.save()
|
|
o := *org
|
|
s.mu.Unlock()
|
|
s.audit(o.ID, p.User.Email, "org.create", o.Name)
|
|
writeJSON(w, 200, o)
|
|
}
|
|
|
|
func (s *Server) updateOrg(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
var in struct {
|
|
Name string
|
|
AllowLocal *bool
|
|
}
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
o := s.findOrg(c.Org.ID)
|
|
if n := strings.TrimSpace(in.Name); n != "" {
|
|
o.Name = n
|
|
}
|
|
if in.AllowLocal != nil {
|
|
if !c.User.ServerAdmin {
|
|
fail(w, 403, errors.New("only a server administrator can change this"))
|
|
return
|
|
}
|
|
o.AllowLocal = *in.AllowLocal
|
|
}
|
|
s.bump()
|
|
ok(w)
|
|
}
|
|
|
|
func (s *Server) deleteOrg(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
if !c.User.ServerAdmin {
|
|
fail(w, 403, errors.New("server administrators only"))
|
|
return
|
|
}
|
|
id := c.Org.ID
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.st.Orgs = filter(s.st.Orgs, func(o *Org) bool { return o.ID != id })
|
|
s.st.Roles = filter(s.st.Roles, func(o *Role) bool { return o.OrgID != id })
|
|
s.st.Members = filter(s.st.Members, func(o *Member) bool { return o.OrgID != id })
|
|
s.st.Invites = filter(s.st.Invites, func(o *Invite) bool { return o.OrgID != id })
|
|
s.st.APIKeys = filter(s.st.APIKeys, func(o *APIKey) bool { return o.OrgID != id })
|
|
s.st.Sites = filter(s.st.Sites, func(o *model.Site) bool { return o.OrgID != id })
|
|
s.st.Domains = filter(s.st.Domains, func(o *model.Domain) bool { return o.OrgID != id })
|
|
s.st.Shares = filter(s.st.Shares, func(o *ShareLink) bool { return o.OrgID != id })
|
|
s.st.MailEgress = filter(s.st.MailEgress, func(o *model.MailEgress) bool { return o.OrgID != id })
|
|
s.st.DKIM = filter(s.st.DKIM, func(o *model.DKIMKey) bool { return o.OrgID != id })
|
|
s.st.Channels = filter(s.st.Channels, func(o *AlertChannel) bool { return o.OrgID != id })
|
|
for _, m := range s.st.Mappings {
|
|
if m.OrgID == id {
|
|
delete(s.st.Secrets, m.ID)
|
|
delete(s.st.Totals, m.ID)
|
|
delete(s.status, m.ID)
|
|
}
|
|
}
|
|
s.st.Mappings = filter(s.st.Mappings, func(o *model.Mapping) bool { return o.OrgID != id })
|
|
for _, e := range s.st.Edges {
|
|
e.OrgIDs = removeStr(e.OrgIDs, id)
|
|
}
|
|
s.bump()
|
|
ok(w)
|
|
}
|
|
|
|
func removeStr(in []string, v string) []string {
|
|
out := []string{}
|
|
for _, x := range in {
|
|
if x != v {
|
|
out = append(out, x)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---------- roles ----------
|
|
|
|
func (s *Server) listRoles(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
writeJSON(w, 200, s.rolesLocked(c.Org.ID))
|
|
}
|
|
|
|
func (s *Server) rolesLocked(orgID string) []Role {
|
|
out := []Role{}
|
|
for _, r := range s.st.Roles {
|
|
if r.OrgID == orgID {
|
|
out = append(out, *r)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) saveRole(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
var in Role
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
in.Name = strings.TrimSpace(in.Name)
|
|
if in.Name == "" {
|
|
fail(w, 400, errors.New("name is required"))
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, o := range s.st.Roles {
|
|
if o.OrgID == c.Org.ID && strings.EqualFold(o.Name, in.Name) && o.ID != r.PathValue("role") {
|
|
fail(w, 400, errors.New("a role with that name exists"))
|
|
return
|
|
}
|
|
}
|
|
if id := r.PathValue("role"); id != "" {
|
|
role := s.findRole(id)
|
|
if role == nil || role.OrgID != c.Org.ID {
|
|
fail(w, 404, errors.New("no such role"))
|
|
return
|
|
}
|
|
if role.Builtin {
|
|
fail(w, 400, errors.New("built-in roles can't be edited"))
|
|
return
|
|
}
|
|
role.Name, role.Admin = in.Name, in.Admin
|
|
} else {
|
|
s.st.Roles = append(s.st.Roles, &Role{ID: newID(), OrgID: c.Org.ID, Name: in.Name, Admin: in.Admin})
|
|
}
|
|
s.save()
|
|
s.audit(c.Org.ID, c.Actor, "role.save", in.Name)
|
|
ok(w)
|
|
}
|
|
|
|
func (s *Server) deleteRole(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
id := r.PathValue("role")
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
role := s.findRole(id)
|
|
if role == nil || role.OrgID != c.Org.ID {
|
|
fail(w, 404, errors.New("no such role"))
|
|
return
|
|
}
|
|
if role.Builtin {
|
|
fail(w, 400, errors.New("built-in roles can't be deleted"))
|
|
return
|
|
}
|
|
for _, m := range s.st.Members {
|
|
if m.RoleID == id {
|
|
fail(w, 400, errors.New("that role still has members; move them first"))
|
|
return
|
|
}
|
|
}
|
|
s.st.Roles = filter(s.st.Roles, func(o *Role) bool { return o.ID != id })
|
|
for _, m := range s.st.Mappings {
|
|
m.Auth.AllowedRoles = removeStr(m.Auth.AllowedRoles, id)
|
|
}
|
|
s.revokeOrgVisitorsLocked(c.Org.ID)
|
|
s.bump()
|
|
ok(w)
|
|
}
|
|
|
|
// ---------- members ----------
|
|
|
|
func (s *Server) listMembers(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := []map[string]any{}
|
|
for _, m := range s.st.Members {
|
|
if m.OrgID != c.Org.ID {
|
|
continue
|
|
}
|
|
u := s.findUser(m.UserID)
|
|
if u == nil {
|
|
continue
|
|
}
|
|
out = append(out, map[string]any{"userId": u.ID, "email": u.Email, "name": u.Name, "roleId": m.RoleID,
|
|
"totp": u.TOTPEnabled, "sso": u.IdP != ""})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i]["email"].(string) < out[j]["email"].(string) })
|
|
writeJSON(w, 200, out)
|
|
}
|
|
|
|
// adminCountLocked counts members with an admin role, excluding skip.
|
|
func (s *Server) adminCountLocked(orgID, skip string) int {
|
|
n := 0
|
|
for _, m := range s.st.Members {
|
|
if m.OrgID == orgID && m.UserID != skip {
|
|
if role := s.findRole(m.RoleID); role != nil && role.Admin {
|
|
n++
|
|
}
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func (s *Server) setMemberRole(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
var in struct{ RoleID string }
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
uid := r.PathValue("user")
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
m := s.member(uid, c.Org.ID)
|
|
role := s.findRole(in.RoleID)
|
|
if m == nil || role == nil || role.OrgID != c.Org.ID {
|
|
fail(w, 404, errors.New("no such member or role"))
|
|
return
|
|
}
|
|
if !role.Admin && s.adminCountLocked(c.Org.ID, uid) == 0 {
|
|
fail(w, 400, errors.New("an organization needs at least one admin"))
|
|
return
|
|
}
|
|
m.RoleID = in.RoleID
|
|
s.revokeOrgVisitorsLocked(c.Org.ID) // what they may open just changed
|
|
s.bump()
|
|
s.audit(c.Org.ID, c.Actor, "member.role", uid+" -> "+role.Name)
|
|
ok(w)
|
|
}
|
|
|
|
func (s *Server) removeMember(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
uid := r.PathValue("user")
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.member(uid, c.Org.ID) == nil {
|
|
fail(w, 404, errors.New("no such member"))
|
|
return
|
|
}
|
|
if s.adminCountLocked(c.Org.ID, uid) == 0 {
|
|
fail(w, 400, errors.New("an organization needs at least one admin"))
|
|
return
|
|
}
|
|
s.st.Members = filter(s.st.Members, func(m *Member) bool { return !(m.UserID == uid && m.OrgID == c.Org.ID) })
|
|
for _, m := range s.st.Mappings {
|
|
if m.OrgID == c.Org.ID {
|
|
m.Auth.AllowedUsers = removeStr(m.Auth.AllowedUsers, uid)
|
|
}
|
|
}
|
|
s.revokeOrgVisitorsLocked(c.Org.ID) // the removed member's open sessions end now
|
|
s.bump()
|
|
s.audit(c.Org.ID, c.Actor, "member.remove", uid)
|
|
ok(w)
|
|
}
|
|
|
|
// ---------- invites ----------
|
|
|
|
func (s *Server) listInvites(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := []Invite{}
|
|
for _, i := range s.st.Invites {
|
|
if i.OrgID == c.Org.ID && time.Now().Before(i.Expires) {
|
|
cp := *i
|
|
cp.TokenHash = ""
|
|
out = append(out, cp)
|
|
}
|
|
}
|
|
writeJSON(w, 200, out)
|
|
}
|
|
|
|
func (s *Server) createInvite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
var in struct{ Email, RoleID string }
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
|
|
if !strings.Contains(in.Email, "@") {
|
|
fail(w, 400, errors.New("enter a valid email address"))
|
|
return
|
|
}
|
|
tok := randHex(20)
|
|
s.mu.Lock()
|
|
role := s.findRole(in.RoleID)
|
|
if role == nil || role.OrgID != c.Org.ID {
|
|
s.mu.Unlock()
|
|
fail(w, 400, errors.New("choose a role"))
|
|
return
|
|
}
|
|
s.st.Invites = append(s.st.Invites, &Invite{ID: newID(), OrgID: c.Org.ID, Email: in.Email, RoleID: role.ID,
|
|
TokenHash: sha(tok), Expires: time.Now().Add(7 * 24 * time.Hour), Created: time.Now()})
|
|
s.save()
|
|
s.mu.Unlock()
|
|
link := s.baseURL(r) + "/#invite=" + tok
|
|
emailed := false
|
|
if s.mailConfigured() {
|
|
body := fmt.Sprintf("%s invited you to join %q on Hammerhead.\n\nAccept: %s\n\nThe link works for 7 days.", c.Actor, c.Org.Name, link)
|
|
emailed = s.Mailer(in.Email, "You're invited to "+c.Org.Name, body) == nil
|
|
}
|
|
s.audit(c.Org.ID, c.Actor, "invite.create", in.Email)
|
|
writeJSON(w, 200, map[string]any{"link": link, "emailed": emailed})
|
|
}
|
|
|
|
func (s *Server) deleteInvite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
id := r.PathValue("id")
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.st.Invites = filter(s.st.Invites, func(i *Invite) bool { return !(i.ID == id && i.OrgID == c.Org.ID) })
|
|
s.save()
|
|
ok(w)
|
|
}
|
|
|
|
func (s *Server) inviteInfo(w http.ResponseWriter, r *http.Request) {
|
|
h := sha(r.PathValue("token"))
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, i := range s.st.Invites {
|
|
if i.TokenHash == h && time.Now().Before(i.Expires) {
|
|
org := s.findOrg(i.OrgID)
|
|
writeJSON(w, 200, map[string]any{"email": i.Email, "org": org.Name, "existing": s.userByEmail(i.Email) != nil})
|
|
return
|
|
}
|
|
}
|
|
fail(w, 404, errors.New("this invitation is invalid or has expired"))
|
|
}
|
|
|
|
func (s *Server) acceptInvite(w http.ResponseWriter, r *http.Request) {
|
|
var in struct{ Token, Name, Password string }
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
if !s.allow("invite|"+clientIP(r), 20, 10*time.Minute) {
|
|
fail(w, 429, errors.New("too many attempts"))
|
|
return
|
|
}
|
|
h := sha(in.Token)
|
|
p := s.principalFrom(r)
|
|
var hash string
|
|
s.mu.Lock()
|
|
var inv *Invite
|
|
for _, i := range s.st.Invites {
|
|
if i.TokenHash == h && time.Now().Before(i.Expires) {
|
|
inv = i
|
|
}
|
|
}
|
|
existing := (*User)(nil)
|
|
if inv != nil {
|
|
existing = s.userByEmail(inv.Email)
|
|
}
|
|
s.mu.Unlock()
|
|
if inv == nil {
|
|
fail(w, 404, errors.New("this invitation is invalid or has expired"))
|
|
return
|
|
}
|
|
if existing == nil {
|
|
var err error
|
|
if hash, err = hashPassword(in.Password); err != nil {
|
|
fail(w, 400, err)
|
|
return
|
|
}
|
|
} else if p == nil || p.User.ID != existing.ID {
|
|
fail(w, 400, errors.New("an account with this email already exists: sign in first, then open the link again"))
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
uid := ""
|
|
if existing != nil {
|
|
uid = existing.ID
|
|
} else {
|
|
name := strings.TrimSpace(in.Name)
|
|
if name == "" {
|
|
name = inv.Email
|
|
}
|
|
u := &User{ID: newID(), Email: inv.Email, Name: name, PassHash: hash, Created: time.Now()}
|
|
s.st.Users = append(s.st.Users, u)
|
|
uid = u.ID
|
|
}
|
|
if s.member(uid, inv.OrgID) == nil {
|
|
s.st.Members = append(s.st.Members, &Member{UserID: uid, OrgID: inv.OrgID, RoleID: inv.RoleID})
|
|
}
|
|
s.st.Invites = filter(s.st.Invites, func(i *Invite) bool { return i.ID != inv.ID })
|
|
s.save()
|
|
s.mu.Unlock()
|
|
if existing == nil {
|
|
s.startSession(w, r, uid)
|
|
}
|
|
s.audit(inv.OrgID, inv.Email, "invite.accept", "")
|
|
ok(w)
|
|
}
|
|
|
|
// ---------- API keys ----------
|
|
|
|
func (s *Server) listKeys(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := []APIKey{}
|
|
for _, k := range s.st.APIKeys {
|
|
if k.OrgID == c.Org.ID {
|
|
cp := *k
|
|
cp.Hash = ""
|
|
out = append(out, cp)
|
|
}
|
|
}
|
|
writeJSON(w, 200, out)
|
|
}
|
|
|
|
func (s *Server) createKey(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
var in struct{ Name string }
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
in.Name = strings.TrimSpace(in.Name)
|
|
if in.Name == "" {
|
|
fail(w, 400, errors.New("name is required"))
|
|
return
|
|
}
|
|
id, secret := randHex(4), randHex(24)
|
|
s.mu.Lock()
|
|
s.st.APIKeys = append(s.st.APIKeys, &APIKey{ID: id, OrgID: c.Org.ID, Name: in.Name, Hash: sha(secret), Created: time.Now()})
|
|
s.save()
|
|
s.mu.Unlock()
|
|
s.audit(c.Org.ID, c.Actor, "apikey.create", in.Name)
|
|
writeJSON(w, 200, map[string]string{"key": "hhk_" + id + "_" + secret})
|
|
}
|
|
|
|
func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
id := r.PathValue("id")
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.st.APIKeys = filter(s.st.APIKeys, func(k *APIKey) bool { return !(k.ID == id && k.OrgID == c.Org.ID) })
|
|
s.save()
|
|
s.audit(c.Org.ID, c.Actor, "apikey.delete", id)
|
|
ok(w)
|
|
}
|
|
|
|
// ---------- logs & stats ----------
|
|
|
|
func (s *Server) orgLogs(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
q := r.URL.Query()
|
|
limit, _ := strconv.Atoi(q.Get("limit"))
|
|
before, _ := strconv.ParseInt(q.Get("before"), 10, 64)
|
|
if q.Get("kind") == "audit" {
|
|
writeJSON(w, 200, s.logs.queryAudit(c.Org.ID, limit, before))
|
|
return
|
|
}
|
|
class, _ := strconv.Atoi(q.Get("status"))
|
|
writeJSON(w, 200, s.logs.queryAccess(accessFilter{Org: c.Org.ID, Mapping: q.Get("mapping"), Q: strings.TrimSpace(q.Get("q")),
|
|
Result: q.Get("result"), StatusClass: class, Before: before, Limit: limit}))
|
|
}
|
|
|
|
// orgTraffic returns bytes per hour for each of the org's ports, oldest first.
|
|
func (s *Server) orgTraffic(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
hours, _ := strconv.Atoi(r.URL.Query().Get("hours"))
|
|
s.mu.Lock()
|
|
var ids []string
|
|
for _, m := range s.st.Mappings {
|
|
if m.OrgID == c.Org.ID {
|
|
ids = append(ids, m.ID)
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
writeJSON(w, 200, s.db.trafficHistory(ids, hours))
|
|
}
|
|
|
|
func (s *Server) orgStats(w http.ResponseWriter, r *http.Request, c *orgCtx) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := map[string]Total{}
|
|
for _, m := range s.st.Mappings {
|
|
if m.OrgID == c.Org.ID {
|
|
if t := s.st.Totals[m.ID]; t != nil {
|
|
out[m.ID] = *t
|
|
}
|
|
}
|
|
}
|
|
writeJSON(w, 200, out)
|
|
}
|
|
|
|
// ---------- server admin: users ----------
|
|
|
|
func (s *Server) adminUsers(w http.ResponseWriter, r *http.Request, p *principal) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := []map[string]any{}
|
|
for _, u := range s.st.Users {
|
|
orgs := []string{}
|
|
for _, m := range s.st.Members {
|
|
if m.UserID == u.ID {
|
|
if o := s.findOrg(m.OrgID); o != nil {
|
|
orgs = append(orgs, o.Name)
|
|
}
|
|
}
|
|
}
|
|
out = append(out, map[string]any{"id": u.ID, "email": u.Email, "name": u.Name, "serverAdmin": u.ServerAdmin,
|
|
"totp": u.TOTPEnabled, "sso": u.IdP != "", "orgs": orgs})
|
|
}
|
|
writeJSON(w, 200, out)
|
|
}
|
|
|
|
func (s *Server) adminCreateUser(w http.ResponseWriter, r *http.Request, p *principal) {
|
|
var in struct {
|
|
Email, Name, Password string
|
|
ServerAdmin bool
|
|
}
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
hash, err := hashPassword(in.Password)
|
|
if err != nil {
|
|
fail(w, 400, err)
|
|
return
|
|
}
|
|
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
|
|
if !strings.Contains(in.Email, "@") {
|
|
fail(w, 400, errors.New("enter a valid email address"))
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.userByEmail(in.Email) != nil {
|
|
fail(w, 400, errors.New("that email already has an account"))
|
|
return
|
|
}
|
|
name := strings.TrimSpace(in.Name)
|
|
if name == "" {
|
|
name = in.Email
|
|
}
|
|
s.st.Users = append(s.st.Users, &User{ID: newID(), Email: in.Email, Name: name, PassHash: hash, ServerAdmin: in.ServerAdmin, Created: time.Now()})
|
|
s.save()
|
|
s.audit("", p.User.Email, "user.create", in.Email)
|
|
ok(w)
|
|
}
|
|
|
|
func (s *Server) adminUpdateUser(w http.ResponseWriter, r *http.Request, p *principal) {
|
|
var in struct {
|
|
ServerAdmin *bool
|
|
ResetTOTP bool
|
|
Password string
|
|
SignOut bool
|
|
}
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
var hash string
|
|
if in.Password != "" {
|
|
var err error
|
|
if hash, err = hashPassword(in.Password); err != nil {
|
|
fail(w, 400, err)
|
|
return
|
|
}
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
u := s.findUser(r.PathValue("id"))
|
|
if u == nil {
|
|
fail(w, 404, errors.New("no such user"))
|
|
return
|
|
}
|
|
if in.ServerAdmin != nil {
|
|
if !*in.ServerAdmin && u.ServerAdmin {
|
|
n := 0
|
|
for _, o := range s.st.Users {
|
|
if o.ServerAdmin {
|
|
n++
|
|
}
|
|
}
|
|
if n <= 1 {
|
|
fail(w, 400, errors.New("there must be at least one server administrator"))
|
|
return
|
|
}
|
|
}
|
|
u.ServerAdmin = *in.ServerAdmin
|
|
}
|
|
if in.ResetTOTP {
|
|
u.TOTPEnabled, u.TOTPSecret, u.BackupCodes = false, "", nil
|
|
}
|
|
if hash != "" || in.SignOut {
|
|
if hash != "" {
|
|
u.PassHash = hash
|
|
}
|
|
for k, sess := range s.st.Sessions {
|
|
if sess.UserID == u.ID {
|
|
delete(s.st.Sessions, k)
|
|
}
|
|
}
|
|
}
|
|
s.save()
|
|
s.audit("", p.User.Email, "user.update", u.Email)
|
|
ok(w)
|
|
}
|
|
|
|
func (s *Server) adminDeleteUser(w http.ResponseWriter, r *http.Request, p *principal) {
|
|
id := r.PathValue("id")
|
|
if id == p.User.ID {
|
|
fail(w, 400, errors.New("you can't delete yourself"))
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
u := s.findUser(id)
|
|
if u == nil {
|
|
fail(w, 404, errors.New("no such user"))
|
|
return
|
|
}
|
|
for _, mem := range s.st.Members {
|
|
if mem.UserID == id {
|
|
s.revokeOrgVisitorsLocked(mem.OrgID) // their open sessions on protected resources end now
|
|
}
|
|
}
|
|
s.st.Users = filter(s.st.Users, func(o *User) bool { return o.ID != id })
|
|
s.st.Members = filter(s.st.Members, func(o *Member) bool { return o.UserID != id })
|
|
for k, sess := range s.st.Sessions {
|
|
if sess.UserID == id {
|
|
delete(s.st.Sessions, k)
|
|
}
|
|
}
|
|
for _, m := range s.st.Mappings {
|
|
m.Auth.AllowedUsers = removeStr(m.Auth.AllowedUsers, id)
|
|
}
|
|
s.bump()
|
|
s.audit("", p.User.Email, "user.delete", u.Email)
|
|
ok(w)
|
|
}
|