Add the Hammerhead control plane
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>
This commit is contained in:
1 parent
3daa368233
commit
f521fa8668
35 files changed
+10392
-1
No files matched your search
@@ -0,0 +1,642 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user