Files
Hammerhead/orgs.go
T
VeillaxandClaude Opus 5.5 c585ddbf63
CI / test (push) Successful in 3m20s
Release / release (push) Successful in 5m6s
Review pass on the Lightwell redesign: layout, copy and behavior fixes
Dialog buttons are Cancel then the primary action at the bottom right, and each page has one primary
action. List pages no longer repeat their title. Dates have the month written out. Fields in a row
line up, hints sit under fields instead of inside labels, and long certificate errors are clamped.
Organizations show whether local access is on and name the action; setting a user's password uses a
dialog instead of a browser prompt. Server errors for sign-in and ranges are reworded with a next step,
and the stray scrollbar inside tables is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:38:56 -06:00

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. Wait a few minutes and try again."))
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)
}