Files
Hammerhead/orgs.go
T

717 lines
19 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)
if p.User.ID != "" { // a server key belongs to no user
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)
}
// issueKey stores a new key for the org ("" = a server key) and returns its ID and full token.
func (s *Server) issueKey(w http.ResponseWriter, r *http.Request, org, actor string) {
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: org, Name: in.Name, Hash: sha(secret), Created: time.Now()})
s.save()
s.mu.Unlock()
s.audit(org, actor, "apikey.create", in.Name)
writeJSON(w, 200, map[string]string{"id": id, "key": "hhk_" + id + "_" + secret})
}
func (s *Server) createKey(w http.ResponseWriter, r *http.Request, c *orgCtx) {
s.issueKey(w, r, c.Org.ID, c.Actor)
}
// Server keys manage edges, server-level alerts and organizations, so only a signed-in
// server administrator may mint, list or revoke them (a key can't make more keys).
func (s *Server) withServerAdminUser(fn func(http.ResponseWriter, *http.Request, *principal)) http.HandlerFunc {
return s.withUser(func(w http.ResponseWriter, r *http.Request, p *principal) {
if !p.User.ServerAdmin {
fail(w, 403, errors.New("server administrators only"))
return
}
fn(w, r, p)
})
}
func (s *Server) listServerKeys(w http.ResponseWriter, r *http.Request, p *principal) {
s.mu.Lock()
defer s.mu.Unlock()
out := []APIKey{}
for _, k := range s.st.APIKeys {
if k.OrgID == "" {
cp := *k
cp.Hash = ""
out = append(out, cp)
}
}
writeJSON(w, 200, out)
}
func (s *Server) createServerKey(w http.ResponseWriter, r *http.Request, p *principal) {
s.issueKey(w, r, "", p.User.Email)
}
func (s *Server) deleteServerKey(w http.ResponseWriter, r *http.Request, p *principal) {
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 == "") })
s.save()
s.audit("", p.User.Email, "apikey.delete", id)
ok(w)
}
// listOrgs shows the organizations the caller can reach: a server key sees all of them,
// an org key its own.
func (s *Server) listOrgs(w http.ResponseWriter, r *http.Request) {
p := s.principalFrom(r)
if p == nil {
fail(w, 401, errors.New("Sign in to continue."))
return
}
s.mu.Lock()
defer s.mu.Unlock()
out := []Org{}
for _, o := range s.st.Orgs {
switch {
case p.serverKey() || p.User.ServerAdmin:
out = append(out, *o)
case p.Key != nil:
if p.Key.OrgID == o.ID {
out = append(out, *o)
}
case s.member(p.User.ID, o.ID) != nil:
out = append(out, *o)
}
}
writeJSON(w, 200, out)
}
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)
}