Why: collaborative KeePass use needs one arbiter of "who wrote last" that never sees a master password, plus access control that follows an org chart. What: stdlib-only Go server. Metadata is a single JSON document with one writer and atomic commit; KDBX payloads are versioned blobs on disk. Access comes from a group tree where a grant on a group reaches every member below it. Login is password+TOTP, invite links, or OIDC through a browser-approved device flow. Pushes carry If-Match and are arbitrated by a per-database merge strategy (server-wins by default). Updates are pushed to clients over SSE. Proved: go test ./internal/... covers RFC 6238 vectors, ACL inheritance and isolation, store rollback, blob pruning, every push verdict, and an end-to-end HTTP pass over login, conflict, force, read-only grants, invite redemption, TOTP enrolment, device login and the event stream.
525 lines
14 KiB
Go
525 lines
14 KiB
Go
package server
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"cc.tiredsysadmin.keeporg/internal/secret"
|
|
"cc.tiredsysadmin.keeporg/internal/store"
|
|
)
|
|
|
|
var errForbidden = errors.New("forbidden")
|
|
|
|
// deny maps a domain error onto a response.
|
|
func deny(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case err == nil:
|
|
return
|
|
case errors.Is(err, errForbidden):
|
|
fail(w, http.StatusForbidden, "forbidden", "you do not administer that object")
|
|
default:
|
|
fail(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
}
|
|
}
|
|
|
|
// ---- databases ----
|
|
|
|
func (s *Server) handleCreateDatabase(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
OwnerGroupID string `json:"owner_group_id"`
|
|
MergeStrategy store.Strategy `json:"merge_strategy"`
|
|
}
|
|
if !readJSON(w, r, &req) {
|
|
return
|
|
}
|
|
var out dbView
|
|
err := s.st.Update(func(st *store.State) error {
|
|
if strings.TrimSpace(req.Name) == "" {
|
|
return errors.New("name is required")
|
|
}
|
|
if st.Groups[req.OwnerGroupID] == nil {
|
|
return errors.New("unknown owner group")
|
|
}
|
|
if !st.IsGroupAdmin(u.ID, req.OwnerGroupID) {
|
|
return errForbidden
|
|
}
|
|
strategy := req.MergeStrategy
|
|
if strategy == "" {
|
|
strategy = store.StrategyServerWins
|
|
}
|
|
if !strategy.Valid() {
|
|
return errors.New("unknown merge strategy")
|
|
}
|
|
db := &store.Database{ID: secret.ID(), Name: strings.TrimSpace(req.Name),
|
|
OwnerGroupID: req.OwnerGroupID, MergeStrategy: strategy, UpdatedAt: time.Now()}
|
|
st.Databases[db.ID] = db
|
|
out = toView(db, store.PermAdmin)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
deny(w, err)
|
|
return
|
|
}
|
|
s.notifyList(out.ID)
|
|
writeJSON(w, http.StatusCreated, out)
|
|
}
|
|
|
|
func (s *Server) handlePatchDatabase(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
id := r.PathValue("id")
|
|
var req struct {
|
|
Name *string `json:"name"`
|
|
MergeStrategy *store.Strategy `json:"merge_strategy"`
|
|
}
|
|
if !readJSON(w, r, &req) {
|
|
return
|
|
}
|
|
var out dbView
|
|
err := s.st.Update(func(st *store.State) error {
|
|
db := st.Databases[id]
|
|
if db == nil {
|
|
return errors.New("no such database")
|
|
}
|
|
if st.DatabasePerm(u.ID, id) != store.PermAdmin {
|
|
return errForbidden
|
|
}
|
|
if req.Name != nil {
|
|
if strings.TrimSpace(*req.Name) == "" {
|
|
return errors.New("name may not be empty")
|
|
}
|
|
db.Name = strings.TrimSpace(*req.Name)
|
|
}
|
|
if req.MergeStrategy != nil {
|
|
if !req.MergeStrategy.Valid() {
|
|
return errors.New("unknown merge strategy")
|
|
}
|
|
db.MergeStrategy = *req.MergeStrategy
|
|
}
|
|
out = toView(db, store.PermAdmin)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
deny(w, err)
|
|
return
|
|
}
|
|
s.notifyList(id)
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (s *Server) handleDeleteDatabase(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
id := r.PathValue("id")
|
|
var watchers []string
|
|
err := s.st.Update(func(st *store.State) error {
|
|
if st.Databases[id] == nil {
|
|
return errors.New("no such database")
|
|
}
|
|
if st.DatabasePerm(u.ID, id) != store.PermAdmin {
|
|
return errForbidden
|
|
}
|
|
watchers = st.Watchers(id)
|
|
delete(st.Databases, id)
|
|
st.Grants = filterGrants(st.Grants, func(g store.Grant) bool { return g.DatabaseID != id })
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
deny(w, err)
|
|
return
|
|
}
|
|
if err := s.blobs.Forget(id); err != nil {
|
|
s.log.Printf("delete blobs for %s: %v", id, err)
|
|
}
|
|
s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: id})
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
}
|
|
|
|
// ---- groups and membership ----
|
|
|
|
type groupView struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
ParentID string `json:"parent_id,omitempty"`
|
|
Admin bool `json:"admin"`
|
|
Members int `json:"members"`
|
|
}
|
|
|
|
func (s *Server) handleListGroups(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
out := []groupView{}
|
|
s.st.Read(func(st *store.State) {
|
|
counts := map[string]int{}
|
|
for _, m := range st.Memberships {
|
|
counts[m.GroupID]++
|
|
}
|
|
reach := st.EffectiveGroups(u.ID)
|
|
for id, g := range st.Groups {
|
|
admin := st.IsGroupAdmin(u.ID, id)
|
|
visible := admin || reach[id] || u.ServerAdmin
|
|
if !visible {
|
|
// A group below one the user administers is visible too.
|
|
for _, anc := range st.Ancestors(id) {
|
|
if st.IsGroupAdmin(u.ID, anc) {
|
|
visible = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if visible {
|
|
out = append(out, groupView{ID: id, Name: g.Name, ParentID: g.ParentID,
|
|
Admin: admin, Members: counts[id]})
|
|
}
|
|
}
|
|
})
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
writeJSON(w, http.StatusOK, map[string]any{"groups": out})
|
|
}
|
|
|
|
func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
ParentID string `json:"parent_id"`
|
|
}
|
|
if !readJSON(w, r, &req) {
|
|
return
|
|
}
|
|
var out groupView
|
|
err := s.st.Update(func(st *store.State) error {
|
|
if strings.TrimSpace(req.Name) == "" {
|
|
return errors.New("name is required")
|
|
}
|
|
if req.ParentID == "" {
|
|
if !u.ServerAdmin {
|
|
return errForbidden
|
|
}
|
|
} else {
|
|
if st.Groups[req.ParentID] == nil {
|
|
return errors.New("unknown parent group")
|
|
}
|
|
if !st.IsGroupAdmin(u.ID, req.ParentID) {
|
|
return errForbidden
|
|
}
|
|
}
|
|
g := &store.Group{ID: secret.ID(), Name: strings.TrimSpace(req.Name),
|
|
ParentID: req.ParentID, CreatedAt: time.Now()}
|
|
st.Groups[g.ID] = g
|
|
out = groupView{ID: g.ID, Name: g.Name, ParentID: g.ParentID, Admin: true}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
deny(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, out)
|
|
}
|
|
|
|
func (s *Server) handleDeleteGroup(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
id := r.PathValue("id")
|
|
err := s.st.Update(func(st *store.State) error {
|
|
g := st.Groups[id]
|
|
if g == nil {
|
|
return errors.New("no such group")
|
|
}
|
|
if !st.IsGroupAdmin(u.ID, id) {
|
|
return errForbidden
|
|
}
|
|
if len(st.Descendants(id)) > 1 {
|
|
return errors.New("the group still has child groups")
|
|
}
|
|
for _, db := range st.Databases {
|
|
if db.OwnerGroupID == id {
|
|
return errors.New("the group still owns databases")
|
|
}
|
|
}
|
|
for _, m := range st.Memberships {
|
|
if m.GroupID == id {
|
|
return errors.New("the group still has members")
|
|
}
|
|
}
|
|
delete(st.Groups, id)
|
|
st.Grants = filterGrants(st.Grants, func(gr store.Grant) bool { return gr.GroupID != id })
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
deny(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
}
|
|
|
|
func (s *Server) handleSetMember(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
groupID := r.PathValue("id")
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
UserID string `json:"user_id"`
|
|
Role store.Role `json:"role"`
|
|
}
|
|
if !readJSON(w, r, &req) {
|
|
return
|
|
}
|
|
err := s.st.Update(func(st *store.State) error {
|
|
if st.Groups[groupID] == nil {
|
|
return errors.New("no such group")
|
|
}
|
|
if !st.IsGroupAdmin(u.ID, groupID) {
|
|
return errForbidden
|
|
}
|
|
target := st.Users[req.UserID]
|
|
if target == nil && req.Username != "" {
|
|
target = st.UserByName(req.Username)
|
|
}
|
|
if target == nil {
|
|
return errors.New("no such user")
|
|
}
|
|
role := req.Role
|
|
if role == "" {
|
|
role = store.RoleMember
|
|
}
|
|
if !role.Valid() {
|
|
return errors.New("role must be member or admin")
|
|
}
|
|
for i := range st.Memberships {
|
|
if st.Memberships[i].UserID == target.ID && st.Memberships[i].GroupID == groupID {
|
|
st.Memberships[i].Role = role
|
|
return nil
|
|
}
|
|
}
|
|
st.Memberships = append(st.Memberships, store.Membership{UserID: target.ID, GroupID: groupID, Role: role})
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
deny(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
groupID, userRef := r.PathValue("id"), r.PathValue("user")
|
|
err := s.st.Update(func(st *store.State) error {
|
|
if st.Groups[groupID] == nil {
|
|
return errors.New("no such group")
|
|
}
|
|
if !st.IsGroupAdmin(u.ID, groupID) {
|
|
return errForbidden
|
|
}
|
|
target := st.Users[userRef]
|
|
if target == nil {
|
|
target = st.UserByName(userRef)
|
|
}
|
|
if target == nil {
|
|
return errors.New("no such user")
|
|
}
|
|
out := st.Memberships[:0]
|
|
for _, m := range st.Memberships {
|
|
if m.UserID == target.ID && m.GroupID == groupID {
|
|
continue
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
st.Memberships = out
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
deny(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "removed"})
|
|
}
|
|
|
|
// ---- grants ----
|
|
|
|
func (s *Server) handleSetGrant(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
var req struct {
|
|
GroupID string `json:"group_id"`
|
|
DatabaseID string `json:"database_id"`
|
|
Perm store.Perm `json:"perm"`
|
|
}
|
|
if !readJSON(w, r, &req) {
|
|
return
|
|
}
|
|
var watchers []string
|
|
err := s.st.Update(func(st *store.State) error {
|
|
if st.Databases[req.DatabaseID] == nil {
|
|
return errors.New("no such database")
|
|
}
|
|
if st.Groups[req.GroupID] == nil {
|
|
return errors.New("no such group")
|
|
}
|
|
if st.DatabasePerm(u.ID, req.DatabaseID) != store.PermAdmin {
|
|
return errForbidden
|
|
}
|
|
if !req.Perm.Valid() {
|
|
return errors.New("perm must be read, write or admin")
|
|
}
|
|
for i := range st.Grants {
|
|
if st.Grants[i].GroupID == req.GroupID && st.Grants[i].DatabaseID == req.DatabaseID {
|
|
st.Grants[i].Perm = req.Perm
|
|
watchers = st.Watchers(req.DatabaseID)
|
|
return nil
|
|
}
|
|
}
|
|
st.Grants = append(st.Grants, store.Grant{GroupID: req.GroupID,
|
|
DatabaseID: req.DatabaseID, Perm: req.Perm})
|
|
watchers = st.Watchers(req.DatabaseID)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
deny(w, err)
|
|
return
|
|
}
|
|
s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: req.DatabaseID})
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) handleDeleteGrant(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
groupID := r.URL.Query().Get("group_id")
|
|
dbID := r.URL.Query().Get("database_id")
|
|
var watchers []string
|
|
err := s.st.Update(func(st *store.State) error {
|
|
if st.Databases[dbID] == nil {
|
|
return errors.New("no such database")
|
|
}
|
|
if st.DatabasePerm(u.ID, dbID) != store.PermAdmin {
|
|
return errForbidden
|
|
}
|
|
watchers = st.Watchers(dbID)
|
|
st.Grants = filterGrants(st.Grants, func(g store.Grant) bool {
|
|
return g.GroupID != groupID || g.DatabaseID != dbID
|
|
})
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
deny(w, err)
|
|
return
|
|
}
|
|
s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: dbID})
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
}
|
|
|
|
// ---- invites ----
|
|
|
|
func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
var req struct {
|
|
GroupID string `json:"group_id"`
|
|
Role store.Role `json:"role"`
|
|
TTLHours int `json:"ttl_hours"`
|
|
MaxUses int `json:"max_uses"`
|
|
}
|
|
if !readJSON(w, r, &req) {
|
|
return
|
|
}
|
|
inv := &store.Invite{Token: secret.Token(), GroupID: req.GroupID, Role: req.Role,
|
|
CreatedBy: u.ID, CreatedAt: time.Now(), MaxUses: req.MaxUses}
|
|
if inv.Role == "" {
|
|
inv.Role = store.RoleMember
|
|
}
|
|
if req.TTLHours <= 0 {
|
|
req.TTLHours = 168
|
|
}
|
|
if inv.MaxUses == 0 {
|
|
inv.MaxUses = 1
|
|
}
|
|
inv.ExpiresAt = time.Now().Add(time.Duration(req.TTLHours) * time.Hour)
|
|
err := s.st.Update(func(st *store.State) error {
|
|
if inv.GroupID == "" {
|
|
if !u.ServerAdmin {
|
|
return errors.New("group_id is required")
|
|
}
|
|
} else {
|
|
if st.Groups[inv.GroupID] == nil {
|
|
return errors.New("unknown group")
|
|
}
|
|
if !st.IsGroupAdmin(u.ID, inv.GroupID) {
|
|
return errForbidden
|
|
}
|
|
}
|
|
if !inv.Role.Valid() {
|
|
return errors.New("role must be member or admin")
|
|
}
|
|
st.Invites[inv.Token] = inv
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
deny(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"token": inv.Token,
|
|
"url": s.cfg.ExternalURL + "/invite/" + inv.Token,
|
|
"expires_at": inv.ExpiresAt,
|
|
"max_uses": inv.MaxUses,
|
|
"group_id": inv.GroupID,
|
|
"role": inv.Role,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleListInvites(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
type inviteView struct {
|
|
Token string `json:"token"`
|
|
URL string `json:"url"`
|
|
GroupID string `json:"group_id,omitempty"`
|
|
Role store.Role `json:"role"`
|
|
Uses int `json:"uses"`
|
|
MaxUses int `json:"max_uses"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
}
|
|
out := []inviteView{}
|
|
s.st.Read(func(st *store.State) {
|
|
for _, inv := range st.Invites {
|
|
if !u.ServerAdmin && inv.CreatedBy != u.ID &&
|
|
!(inv.GroupID != "" && st.IsGroupAdmin(u.ID, inv.GroupID)) {
|
|
continue
|
|
}
|
|
out = append(out, inviteView{Token: inv.Token, URL: s.cfg.ExternalURL + "/invite/" + inv.Token,
|
|
GroupID: inv.GroupID, Role: inv.Role, Uses: inv.Uses, MaxUses: inv.MaxUses,
|
|
ExpiresAt: inv.ExpiresAt})
|
|
}
|
|
})
|
|
sort.Slice(out, func(i, j int) bool { return out[i].ExpiresAt.Before(out[j].ExpiresAt) })
|
|
writeJSON(w, http.StatusOK, map[string]any{"invites": out})
|
|
}
|
|
|
|
// ---- users ----
|
|
|
|
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
out := []userView{}
|
|
s.st.Read(func(st *store.State) {
|
|
for id, target := range st.Users {
|
|
if u.ServerAdmin || id == u.ID || s.sharesAdministeredGroup(st, u.ID, id) {
|
|
out = append(out, view(target))
|
|
}
|
|
}
|
|
})
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username })
|
|
writeJSON(w, http.StatusOK, map[string]any{"users": out})
|
|
}
|
|
|
|
// sharesAdministeredGroup reports whether admin administers any group the
|
|
// target belongs to, which is what makes the target visible to them.
|
|
func (s *Server) sharesAdministeredGroup(st *store.State, adminID, targetID string) bool {
|
|
for _, m := range st.Memberships {
|
|
if m.UserID == targetID && st.IsGroupAdmin(adminID, m.GroupID) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// notifyList tells everyone with access that the database list changed.
|
|
func (s *Server) notifyList(dbID string) {
|
|
var watchers []string
|
|
s.st.Read(func(st *store.State) { watchers = st.Watchers(dbID) })
|
|
s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: dbID})
|
|
}
|
|
|
|
func filterGrants(in []store.Grant, keep func(store.Grant) bool) []store.Grant {
|
|
out := in[:0]
|
|
for _, g := range in {
|
|
if keep(g) {
|
|
out = append(out, g)
|
|
}
|
|
}
|
|
return out
|
|
}
|