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
+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")
}
}