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.
224 lines
6.8 KiB
Go
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
|
|
}
|