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
+143
View File
@@ -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
}
+90
View File
@@ -0,0 +1,90 @@
package store
import "testing"
// tree: company > engineering > backend, company > sales
func aclFixture() *State {
s := newState()
s.Groups["company"] = &Group{ID: "company", Name: "Company"}
s.Groups["eng"] = &Group{ID: "eng", Name: "Engineering", ParentID: "company"}
s.Groups["backend"] = &Group{ID: "backend", Name: "Backend", ParentID: "eng"}
s.Groups["sales"] = &Group{ID: "sales", Name: "Sales", ParentID: "company"}
s.Users["alice"] = &User{ID: "alice", Username: "alice"}
s.Users["bob"] = &User{ID: "bob", Username: "bob"}
s.Users["root"] = &User{ID: "root", Username: "root", ServerAdmin: true}
s.Memberships = []Membership{
{UserID: "alice", GroupID: "backend", Role: RoleMember},
{UserID: "bob", GroupID: "sales", Role: RoleAdmin},
}
s.Databases["shared"] = &Database{ID: "shared", Name: "Shared", OwnerGroupID: "company"}
s.Databases["salesdb"] = &Database{ID: "salesdb", Name: "Sales", OwnerGroupID: "sales"}
s.Grants = []Grant{
{GroupID: "company", DatabaseID: "shared", Perm: PermRead},
{GroupID: "eng", DatabaseID: "shared", Perm: PermWrite},
}
return s
}
func TestGrantOnAncestorReachesDescendantMember(t *testing.T) {
s := aclFixture()
if got := s.DatabasePerm("alice", "shared"); got != PermWrite {
t.Fatalf("alice on shared = %q, want write (read from company, write from eng)", got)
}
}
func TestGrantDoesNotLeakAcrossSiblings(t *testing.T) {
s := aclFixture()
if got := s.DatabasePerm("alice", "salesdb"); got != PermNone {
t.Fatalf("alice on salesdb = %q, want none", got)
}
if got := s.DatabasePerm("bob", "shared"); got != PermRead {
t.Fatalf("bob on shared = %q, want read", got)
}
}
func TestOwnerGroupAdminGetsAdmin(t *testing.T) {
s := aclFixture()
if got := s.DatabasePerm("bob", "salesdb"); got != PermAdmin {
t.Fatalf("bob on own group db = %q, want admin", got)
}
if !s.IsGroupAdmin("root", "backend") {
t.Fatal("server admin should administer any group")
}
if s.IsGroupAdmin("bob", "eng") {
t.Fatal("sales admin must not administer engineering")
}
}
func TestAdminOfParentAdministersChild(t *testing.T) {
s := aclFixture()
s.Memberships = append(s.Memberships, Membership{UserID: "alice", GroupID: "company", Role: RoleAdmin})
if !s.IsGroupAdmin("alice", "backend") {
t.Fatal("company admin should administer backend")
}
if got := s.DatabasePerm("alice", "salesdb"); got != PermAdmin {
t.Fatalf("company admin on salesdb = %q, want admin", got)
}
}
func TestAssignmentsAndWatchers(t *testing.T) {
s := aclFixture()
got := s.Assignments("alice")
if len(got) != 1 || got[0].DB.ID != "shared" || got[0].Perm != PermWrite {
t.Fatalf("alice assignments = %+v", got)
}
w := s.Watchers("shared")
if len(w) != 3 {
t.Fatalf("watchers of shared = %v, want alice, bob and root", w)
}
}
func TestTreeWalksSurviveCycle(t *testing.T) {
s := aclFixture()
s.Groups["company"].ParentID = "backend" // corrupt
if len(s.Ancestors("backend")) != 3 {
t.Fatalf("cycle walk = %v", s.Ancestors("backend"))
}
if len(s.Descendants("company")) == 0 {
t.Fatal("descendants must terminate on a cycle")
}
}
+223
View File
@@ -0,0 +1,223 @@
// Package store holds the server's authoritative metadata: users, nested
// groups, databases and the grants that connect them.
//
// Glossary:
//
// User an account, authenticated by password+TOTP or by OIDC.
// Group a node in a single tree of groups. Grants hang off groups.
// Membership a user's place in one group, as member or admin.
// Database one KDBX file tracked by the server. Owned by one group.
// Grant a (group, database, permission) triple.
// Invite a single- or multi-use registration token.
// Session a bearer token held by a client device.
package store
import "time"
// Perm is a permission on a database, ordered read < write < admin.
type Perm string
const (
PermNone Perm = ""
PermRead Perm = "read"
PermWrite Perm = "write"
PermAdmin Perm = "admin"
)
var permRank = map[Perm]int{PermNone: 0, PermRead: 1, PermWrite: 2, PermAdmin: 3}
// Rank orders permissions so the strongest of several grants can be picked.
func (p Perm) Rank() int { return permRank[p] }
// AtLeast reports whether p is as strong as want.
func (p Perm) AtLeast(want Perm) bool { return p.Rank() >= want.Rank() }
// Valid reports whether p is a permission that may be stored.
func (p Perm) Valid() bool { return p == PermRead || p == PermWrite || p == PermAdmin }
// Role is a user's role inside a group.
type Role string
const (
RoleMember Role = "member"
RoleAdmin Role = "admin"
)
// Valid reports whether r is a role that may be stored.
func (r Role) Valid() bool { return r == RoleMember || r == RoleAdmin }
// Strategy decides what happens when a client pushes a database version that
// is no longer current.
type Strategy string
const (
// StrategyServerWins rejects the stale push; the client takes the server
// copy and keeps its own as a conflict file. This is the default.
StrategyServerWins Strategy = "server-wins"
// StrategyClientWins lets the client overwrite the server copy.
StrategyClientWins Strategy = "client-wins"
// StrategyManual rejects the stale push and leaves both copies in place
// for a human to reconcile.
StrategyManual Strategy = "manual"
)
// Valid reports whether s is a strategy that may be stored.
func (s Strategy) Valid() bool {
return s == StrategyServerWins || s == StrategyClientWins || s == StrategyManual
}
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
PassHash string `json:"pass_hash,omitempty"`
OIDCIssuer string `json:"oidc_issuer,omitempty"`
OIDCSubject string `json:"oidc_subject,omitempty"`
TOTPSecret string `json:"totp_secret,omitempty"`
TOTPConfirmed bool `json:"totp_confirmed"`
ServerAdmin bool `json:"server_admin"`
Disabled bool `json:"disabled"`
CreatedAt time.Time `json:"created_at"`
}
type Group struct {
ID string `json:"id"`
Name string `json:"name"`
ParentID string `json:"parent_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type Membership struct {
UserID string `json:"user_id"`
GroupID string `json:"group_id"`
Role Role `json:"role"`
}
type Database struct {
ID string `json:"id"`
Name string `json:"name"`
OwnerGroupID string `json:"owner_group_id"`
MergeStrategy Strategy `json:"merge_strategy"`
Version int64 `json:"version"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
UpdatedAt time.Time `json:"updated_at"`
UpdatedBy string `json:"updated_by,omitempty"`
}
type Grant struct {
GroupID string `json:"group_id"`
DatabaseID string `json:"database_id"`
Perm Perm `json:"perm"`
}
type Invite struct {
Token string `json:"token"`
GroupID string `json:"group_id,omitempty"`
Role Role `json:"role"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
MaxUses int `json:"max_uses"`
Uses int `json:"uses"`
}
// Usable reports whether the invite may still be redeemed at time now.
func (i *Invite) Usable(now time.Time) bool {
return now.Before(i.ExpiresAt) && (i.MaxUses <= 0 || i.Uses < i.MaxUses)
}
type Session struct {
Token string `json:"token"`
UserID string `json:"user_id"`
Label string `json:"label,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
// DeviceAuth backs the browser-assisted login used for OIDC on desktop and
// Android: the client shows a short user code, the browser approves it, the
// client polls until a session token appears.
type DeviceAuth struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
Label string `json:"label,omitempty"`
SessionToken string `json:"session_token,omitempty"`
OIDCState string `json:"oidc_state,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
// State is the whole metadata set, serialized as one JSON document.
type State struct {
Users map[string]*User `json:"users"`
Groups map[string]*Group `json:"groups"`
Databases map[string]*Database `json:"databases"`
Invites map[string]*Invite `json:"invites"`
Sessions map[string]*Session `json:"sessions"`
Devices map[string]*DeviceAuth `json:"devices"`
Memberships []Membership `json:"memberships"`
Grants []Grant `json:"grants"`
}
func newState() *State {
return &State{
Users: map[string]*User{},
Groups: map[string]*Group{},
Databases: map[string]*Database{},
Invites: map[string]*Invite{},
Sessions: map[string]*Session{},
Devices: map[string]*DeviceAuth{},
}
}
func (s *State) init() {
if s.Users == nil {
s.Users = map[string]*User{}
}
if s.Groups == nil {
s.Groups = map[string]*Group{}
}
if s.Databases == nil {
s.Databases = map[string]*Database{}
}
if s.Invites == nil {
s.Invites = map[string]*Invite{}
}
if s.Sessions == nil {
s.Sessions = map[string]*Session{}
}
if s.Devices == nil {
s.Devices = map[string]*DeviceAuth{}
}
}
// UserByName finds a user by exact username.
func (s *State) UserByName(name string) *User {
for _, u := range s.Users {
if u.Username == name {
return u
}
}
return nil
}
// UserByOIDC finds a user previously linked to an OIDC issuer and subject.
func (s *State) UserByOIDC(issuer, subject string) *User {
for _, u := range s.Users {
if u.OIDCIssuer == issuer && u.OIDCSubject == subject {
return u
}
}
return nil
}
// DeviceByUserCode finds a pending device login by the code shown to the user.
func (s *State) DeviceByUserCode(code string) *DeviceAuth {
for _, d := range s.Devices {
if d.UserCode == code {
return d
}
}
return nil
}
+136
View File
@@ -0,0 +1,136 @@
package store
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// Store is the single writer of the metadata document. Every mutation runs on
// a private copy of the state and is swapped in only once it has reached the
// disk, so a reader never observes a half-applied update and a crash never
// leaves a partial file.
type Store struct {
path string
mu sync.RWMutex
s *State
}
// Open loads the state document at path, creating an empty one if absent.
func Open(path string) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
st := &Store{path: path, s: newState()}
b, err := os.ReadFile(path)
switch {
case os.IsNotExist(err):
return st, st.persist(st.s)
case err != nil:
return nil, err
}
s := newState()
if err := json.Unmarshal(b, s); err != nil {
return nil, fmt.Errorf("store: parse %s: %w", path, err)
}
s.init()
st.s = s
return st, nil
}
// Read runs fn against a consistent view of the state. fn must not retain or
// mutate anything it is given.
func (st *Store) Read(fn func(*State)) {
st.mu.RLock()
defer st.mu.RUnlock()
fn(st.s)
}
// Update applies fn to a copy of the state and commits it. If fn or the write
// fails, the stored state is left untouched.
func (st *Store) Update(fn func(*State) error) error {
st.mu.Lock()
defer st.mu.Unlock()
next, err := clone(st.s)
if err != nil {
return err
}
if err := fn(next); err != nil {
return err
}
if err := st.persist(next); err != nil {
return err
}
st.s = next
return nil
}
// GC drops expired sessions, device logins and invites.
func (st *Store) GC(now time.Time) error {
return st.Update(func(s *State) error {
for k, v := range s.Sessions {
if now.After(v.ExpiresAt) {
delete(s.Sessions, k)
}
}
for k, v := range s.Devices {
if now.After(v.ExpiresAt) {
delete(s.Devices, k)
}
}
for k, v := range s.Invites {
if !v.Usable(now) {
delete(s.Invites, k)
}
}
return nil
})
}
func (st *Store) persist(s *State) error {
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
return writeFileAtomic(st.path, b, 0o600)
}
func clone(s *State) (*State, error) {
b, err := json.Marshal(s)
if err != nil {
return nil, err
}
out := newState()
if err := json.Unmarshal(b, out); err != nil {
return nil, err
}
out.init()
return out, nil
}
// writeFileAtomic replaces path with data, surviving a crash mid-write.
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
tmp := path + ".tmp"
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err := f.Write(data); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Sync(); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
}
+76
View File
@@ -0,0 +1,76 @@
package store
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
)
func TestUpdatePersistsAndSurvivesReopen(t *testing.T) {
p := filepath.Join(t.TempDir(), "state.json")
st, err := Open(p)
if err != nil {
t.Fatal(err)
}
if err := st.Update(func(s *State) error {
s.Users["u1"] = &User{ID: "u1", Username: "alice"}
return nil
}); err != nil {
t.Fatal(err)
}
again, err := Open(p)
if err != nil {
t.Fatal(err)
}
var name string
again.Read(func(s *State) { name = s.Users["u1"].Username })
if name != "alice" {
t.Fatalf("reopened username = %q", name)
}
if fi, _ := os.Stat(p); fi.Mode().Perm() != 0o600 {
t.Fatalf("state file mode = %v", fi.Mode().Perm())
}
}
func TestFailedUpdateLeavesStateUntouched(t *testing.T) {
st, err := Open(filepath.Join(t.TempDir(), "state.json"))
if err != nil {
t.Fatal(err)
}
boom := errors.New("boom")
if err := st.Update(func(s *State) error {
s.Users["u1"] = &User{ID: "u1"}
return boom
}); !errors.Is(err, boom) {
t.Fatalf("err = %v", err)
}
var n int
st.Read(func(s *State) { n = len(s.Users) })
if n != 0 {
t.Fatalf("users after failed update = %d", n)
}
}
func TestGCDropsExpired(t *testing.T) {
st, _ := Open(filepath.Join(t.TempDir(), "state.json"))
now := time.Now()
_ = st.Update(func(s *State) error {
s.Sessions["old"] = &Session{Token: "old", ExpiresAt: now.Add(-time.Hour)}
s.Sessions["new"] = &Session{Token: "new", ExpiresAt: now.Add(time.Hour)}
s.Invites["spent"] = &Invite{Token: "spent", ExpiresAt: now.Add(time.Hour), MaxUses: 1, Uses: 1}
return nil
})
if err := st.GC(now); err != nil {
t.Fatal(err)
}
st.Read(func(s *State) {
if len(s.Sessions) != 1 || s.Sessions["new"] == nil {
t.Fatalf("sessions after GC = %v", s.Sessions)
}
if len(s.Invites) != 0 {
t.Fatalf("invites after GC = %v", s.Invites)
}
})
}