Add KeepOrg sync server: accounts, nested groups, versioned KDBX sync

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.
This commit is contained in:
2026-10-29 15:54:10 +01:00
commit 8bbb3179a1
26 changed files with 3897 additions and 0 deletions
+524
View File
@@ -0,0 +1,524 @@
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
}