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:
@@ -0,0 +1,143 @@
|
||||
package store
|
||||
|
||||
import "sort"
|
||||
|
||||
// maxDepth bounds every tree walk so a corrupted parent cycle cannot hang the
|
||||
// server. Group creation rejects cycles, so this is a backstop only.
|
||||
const maxDepth = 64
|
||||
|
||||
// Ancestors returns groupID and every parent above it, nearest first.
|
||||
func (s *State) Ancestors(groupID string) []string {
|
||||
out := []string{}
|
||||
seen := map[string]bool{}
|
||||
for id := groupID; id != "" && !seen[id] && len(out) < maxDepth; {
|
||||
g, ok := s.Groups[id]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
seen[id] = true
|
||||
out = append(out, id)
|
||||
id = g.ParentID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Descendants returns groupID and every group below it.
|
||||
func (s *State) Descendants(groupID string) []string {
|
||||
if _, ok := s.Groups[groupID]; !ok {
|
||||
return nil
|
||||
}
|
||||
out := []string{groupID}
|
||||
for i := 0; i < len(out); i++ {
|
||||
for id, g := range s.Groups {
|
||||
if g.ParentID == out[i] && !contains(out, id) {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// IsAncestor reports whether anc is at or above desc in the tree.
|
||||
func (s *State) IsAncestor(anc, desc string) bool {
|
||||
return contains(s.Ancestors(desc), anc)
|
||||
}
|
||||
|
||||
// EffectiveGroups is the set of groups whose grants reach the user: every
|
||||
// group the user belongs to, plus every group above those. A grant on
|
||||
// "Company" therefore reaches a member of "Company/Engineering/Backend".
|
||||
func (s *State) EffectiveGroups(userID string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, m := range s.Memberships {
|
||||
if m.UserID != userID {
|
||||
continue
|
||||
}
|
||||
for _, id := range s.Ancestors(m.GroupID) {
|
||||
out[id] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsGroupAdmin reports whether the user administers groupID, either directly
|
||||
// or by administering a group above it. Server admins administer everything.
|
||||
func (s *State) IsGroupAdmin(userID, groupID string) bool {
|
||||
if u := s.Users[userID]; u != nil && u.ServerAdmin {
|
||||
return true
|
||||
}
|
||||
anc := s.Ancestors(groupID)
|
||||
for _, m := range s.Memberships {
|
||||
if m.UserID == userID && m.Role == RoleAdmin && contains(anc, m.GroupID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DatabasePerm is the strongest permission the user holds on a database.
|
||||
func (s *State) DatabasePerm(userID, dbID string) Perm {
|
||||
db, ok := s.Databases[dbID]
|
||||
if !ok {
|
||||
return PermNone
|
||||
}
|
||||
if s.IsGroupAdmin(userID, db.OwnerGroupID) {
|
||||
return PermAdmin
|
||||
}
|
||||
reach := s.EffectiveGroups(userID)
|
||||
best := PermNone
|
||||
for _, g := range s.Grants {
|
||||
if g.DatabaseID != dbID || !reach[g.GroupID] {
|
||||
continue
|
||||
}
|
||||
if g.Perm.Rank() > best.Rank() {
|
||||
best = g.Perm
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// Assignment pairs a database with the caller's permission on it.
|
||||
type Assignment struct {
|
||||
DB *Database
|
||||
Perm Perm
|
||||
}
|
||||
|
||||
// Assignments lists every database the user may see, strongest permission
|
||||
// first resolved, sorted by name for a stable client view.
|
||||
func (s *State) Assignments(userID string) []Assignment {
|
||||
out := []Assignment{}
|
||||
for id, db := range s.Databases {
|
||||
if p := s.DatabasePerm(userID, id); p != PermNone {
|
||||
out = append(out, Assignment{DB: db, Perm: p})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].DB.Name != out[j].DB.Name {
|
||||
return out[i].DB.Name < out[j].DB.Name
|
||||
}
|
||||
return out[i].DB.ID < out[j].DB.ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Watchers lists the user IDs that must be told when a database changes.
|
||||
func (s *State) Watchers(dbID string) []string {
|
||||
out := []string{}
|
||||
for id := range s.Users {
|
||||
if s.DatabasePerm(id, dbID) != PermNone {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func contains(ss []string, s string) bool {
|
||||
for _, v := range ss {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user