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