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,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)
|
||||
}
|
||||
Reference in New Issue
Block a user