Files
keeporg/internal/store/model.go
T
root 8bbb3179a1 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.
2026-10-29 15:54:10 +01:00

224 lines
6.8 KiB
Go

// 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
}