package server import ( "errors" "net/http" "sort" "strings" "time" "cc.tiredsysadmin.keeporg/internal/secret" "cc.tiredsysadmin.keeporg/internal/store" ) var errForbidden = errors.New("forbidden") // Every administrative change in this file is a function of the state and the // caller; the JSON handlers below and the HTML forms in webadmin.go are thin // wrappers over them. The API and the web interface have to agree about who // may do what, and the only durable way to make them agree is for both to // call the same function. // denyAuth answers a request that failed to re-prove its credentials. The // cases stay distinct so a client knows to prompt for the factor it is // missing rather than reporting a flat refusal. func denyAuth(w http.ResponseWriter, err error) { if writeThrottled(w, err) { return } switch { case errors.Is(err, errNoDatabase): fail(w, http.StatusNotFound, "not_found", err.Error()) case errors.Is(err, errTOTPRequired): fail(w, http.StatusUnauthorized, "totp_required", "your one-time code is required for this") case errors.Is(err, errReauth): fail(w, http.StatusUnauthorized, "bad_credentials", "your own account password is required for this") case errors.Is(err, errBadCredentials): fail(w, http.StatusUnauthorized, "bad_credentials", "your own account password or one-time code is wrong") default: deny(w, err) } } // deny maps a domain error onto a response. func deny(w http.ResponseWriter, err error) { switch { case err == nil: return case errors.Is(err, errNoDatabase): fail(w, http.StatusNotFound, "not_found", err.Error()) case errors.Is(err, errForbidden): fail(w, http.StatusForbidden, "forbidden", "you do not administer that object") default: fail(w, http.StatusBadRequest, "bad_request", err.Error()) } } // ---- visibility ---- // resolveUser finds a user by id or by username, trying each reference in // turn, so a caller may pass whichever of the two it has. func resolveUser(st *store.State, refs ...string) *store.User { for _, ref := range refs { if ref == "" { continue } if u := st.Users[ref]; u != nil { return u } if u := st.UserByName(ref); u != nil { return u } } return nil } // visibleGroups is the set of groups the user may know about: the ones they // administer, the ones whose grants reach them, and everything below a group // they administer. A server administrator sees the whole tree. func visibleGroups(st *store.State, u *store.User) map[string]bool { out := map[string]bool{} reach := st.EffectiveGroups(u.ID) for id := range st.Groups { if u.ServerAdmin || reach[id] || st.IsGroupAdmin(u.ID, id) { out[id] = true continue } for _, anc := range st.Ancestors(id) { if st.IsGroupAdmin(u.ID, anc) { out[id] = true break } } } return out } // sharesAdministeredGroup reports whether admin administers any group the // target belongs to, which is what makes the target visible to them. func sharesAdministeredGroup(st *store.State, adminID, targetID string) bool { for _, m := range st.Memberships { if m.UserID == targetID && st.IsGroupAdmin(adminID, m.GroupID) { return true } } return false } // visibleUsers lists the accounts the caller may see, by username. func visibleUsers(st *store.State, u *store.User) []*store.User { out := []*store.User{} for id, target := range st.Users { if u.ServerAdmin || id == u.ID || sharesAdministeredGroup(st, u.ID, id) { out = append(out, target) } } sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username }) return out } // ---- groups and membership ---- func createGroup(st *store.State, u *store.User, name, parentID string) (*store.Group, error) { name = strings.TrimSpace(name) if name == "" { return nil, errors.New("name is required") } if parentID == "" { // A group with no parent is a new root, which reorganizes the whole // server, so only a server administrator may make one. if !u.ServerAdmin { return nil, errors.New("pick a parent group") } } else { if st.Groups[parentID] == nil { return nil, errors.New("unknown parent group") } if !st.IsGroupAdmin(u.ID, parentID) { return nil, errForbidden } } g := &store.Group{ID: secret.ID(), Name: name, ParentID: parentID, CreatedAt: time.Now()} st.Groups[g.ID] = g return g, nil } func deleteGroup(st *store.State, u *store.User, id string) error { if st.Groups[id] == nil { return errors.New("no such group") } if !st.IsGroupAdmin(u.ID, id) { return errForbidden } if len(st.Descendants(id)) > 1 { return errors.New("the group still has child groups") } for _, db := range st.Databases { if db.OwnerGroupID == id { return errors.New("the group still owns databases") } } for _, m := range st.Memberships { if m.GroupID == id { return errors.New("the group still has members") } } delete(st.Groups, id) st.Grants = filterGrants(st.Grants, func(gr store.Grant) bool { return gr.GroupID != id }) return nil } // setMember adds a user to a group, or changes the role they already have. func setMember(st *store.State, u *store.User, groupID string, role store.Role, refs ...string) error { if st.Groups[groupID] == nil { return errors.New("no such group") } if !st.IsGroupAdmin(u.ID, groupID) { return errForbidden } target := resolveUser(st, refs...) if target == nil { return errors.New("no such user") } if role == "" { role = store.RoleMember } if !role.Valid() { return errors.New("role must be member or admin") } for i := range st.Memberships { if st.Memberships[i].UserID == target.ID && st.Memberships[i].GroupID == groupID { st.Memberships[i].Role = role return nil } } st.Memberships = append(st.Memberships, store.Membership{UserID: target.ID, GroupID: groupID, Role: role}) return nil } func removeMember(st *store.State, u *store.User, groupID string, refs ...string) error { if st.Groups[groupID] == nil { return errors.New("no such group") } if !st.IsGroupAdmin(u.ID, groupID) { return errForbidden } target := resolveUser(st, refs...) if target == nil { return errors.New("no such user") } out := st.Memberships[:0] for _, m := range st.Memberships { if m.UserID == target.ID && m.GroupID == groupID { continue } out = append(out, m) } st.Memberships = out return nil } // ---- grants ---- // adminsDatabase checks that u administers a database. One without any // access cannot tell a database that is not theirs from one that does not // exist; one who can see it is told it is not theirs to change. func adminsDatabase(st *store.State, u *store.User, dbID string) error { perm := st.DatabasePerm(u.ID, dbID) switch { case st.Databases[dbID] == nil || perm == store.PermNone: return errNoDatabase case perm != store.PermAdmin: return errForbidden } return nil } func setGrant(st *store.State, u *store.User, groupID, dbID string, perm store.Perm) error { if err := adminsDatabase(st, u, dbID); err != nil { return err } if st.Groups[groupID] == nil { return errors.New("no such group") } if !perm.Valid() { return errors.New("perm must be read, write or admin") } for i := range st.Grants { if st.Grants[i].GroupID == groupID && st.Grants[i].DatabaseID == dbID { st.Grants[i].Perm = perm return nil } } st.Grants = append(st.Grants, store.Grant{GroupID: groupID, DatabaseID: dbID, Perm: perm}) return nil } func removeGrant(st *store.State, u *store.User, groupID, dbID string) error { if err := adminsDatabase(st, u, dbID); err != nil { return err } st.Grants = filterGrants(st.Grants, func(g store.Grant) bool { return g.GroupID != groupID || g.DatabaseID != dbID }) return nil } // ---- invites ---- // defaultInviteTTLHours is how long an invite lasts when nobody says. const defaultInviteTTLHours = 168 func createInvite(st *store.State, u *store.User, groupID string, role store.Role, ttlHours, maxUses int) (*store.Invite, error) { if role == "" { role = store.RoleMember } if !role.Valid() { return nil, errors.New("role must be member or admin") } if groupID == "" { // An invite into no group creates an account with no access at all, // which is only ever useful to a server administrator. if !u.ServerAdmin { return nil, errors.New("pick the group the new account joins") } } else { if st.Groups[groupID] == nil { return nil, errors.New("unknown group") } if !st.IsGroupAdmin(u.ID, groupID) { return nil, errForbidden } } if ttlHours <= 0 { ttlHours = defaultInviteTTLHours } if maxUses == 0 { maxUses = 1 } inv := &store.Invite{ Token: secret.Token(), GroupID: groupID, Role: role, CreatedBy: u.ID, CreatedAt: time.Now(), MaxUses: maxUses, ExpiresAt: time.Now().Add(time.Duration(ttlHours) * time.Hour), } st.Invites[inv.Token] = inv return inv, nil } // mayManageInvite decides both who sees an invite and who may revoke it: the // person who made it, an administrator of the group it leads into, and any // server administrator. func mayManageInvite(st *store.State, u *store.User, inv *store.Invite) bool { return u.ServerAdmin || inv.CreatedBy == u.ID || (inv.GroupID != "" && st.IsGroupAdmin(u.ID, inv.GroupID)) } func revokeInvite(st *store.State, u *store.User, token string) error { inv := st.Invites[token] if inv == nil { return errors.New("no such invite") } if !mayManageInvite(st, u, inv) { return errForbidden } delete(st.Invites, token) return nil } // ---- accounts ---- // accountAction is a change to an account rather than to its access. type accountAction string const ( accountDisable accountAction = "disable" accountEnable accountAction = "enable" accountPromote accountAction = "promote" accountDemote accountAction = "demote" accountClearTOTP accountAction = "clear-totp" accountSignOut accountAction = "sign-out" accountDelete accountAction = "delete" ) // adminAccount applies an account change. Only a server administrator may: // a group administrator decides who reaches which vault, which is a matter // of access, while disabling an account, clearing somebody's second factor // or deleting them outright reaches beyond the group they happen to share. // // The caller's own account is protected from the three changes that would // lock them out, because a server with no reachable administrator can only // be repaired from a shell on the host. func adminAccount(st *store.State, u *store.User, ref string, act accountAction) (*store.User, error) { if !u.ServerAdmin { return nil, errForbidden } target := resolveUser(st, ref) if target == nil { return nil, errors.New("no such user") } if target.ID == u.ID { switch act { case accountDisable, accountDemote, accountDelete: return nil, errors.New("that would lock you out, so it is refused on your own account") } } switch act { case accountDisable: target.Disabled = true revokeSessions(st, target.ID) case accountEnable: target.Disabled = false case accountPromote: target.ServerAdmin = true case accountDemote: target.ServerAdmin = false case accountClearTOTP: target.TOTPSecret, target.TOTPConfirmed = "", false case accountSignOut: // For a lost phone or laptop: every device must sign in again, // and the account stays usable. revokeSessions(st, target.ID) case accountDelete: delete(st.Users, target.ID) revokeSessions(st, target.ID) out := st.Memberships[:0] for _, m := range st.Memberships { if m.UserID != target.ID { out = append(out, m) } } st.Memberships = out default: return nil, errors.New("unknown action") } return target, nil } // revokeSessions drops every session a user holds, on any device. func revokeSessions(st *store.State, userID string) { for token, sess := range st.Sessions { if sess.UserID == userID { delete(st.Sessions, token) } } } // ---- databases ---- func (s *Server) handleCreateDatabase(w http.ResponseWriter, r *http.Request, u *store.User) { var req struct { Name string `json:"name"` OwnerGroupID string `json:"owner_group_id"` MergeStrategy store.Strategy `json:"merge_strategy"` } if !readJSON(w, r, &req) { return } var out dbView err := s.st.Update(func(st *store.State) error { if strings.TrimSpace(req.Name) == "" { return errors.New("name is required") } if st.Groups[req.OwnerGroupID] == nil { return errors.New("unknown owner group") } if !st.IsGroupAdmin(u.ID, req.OwnerGroupID) { return errForbidden } strategy := req.MergeStrategy if strategy == "" { strategy = store.StrategyServerWins } if !strategy.Valid() { return errors.New("unknown merge strategy") } db := &store.Database{ID: secret.ID(), Name: strings.TrimSpace(req.Name), OwnerGroupID: req.OwnerGroupID, MergeStrategy: strategy, UpdatedAt: time.Now()} st.Databases[db.ID] = db out = toView(db, store.PermAdmin) return nil }) if err != nil { deny(w, err) return } s.notifyList(out.ID) writeJSON(w, http.StatusCreated, out) } func (s *Server) handlePatchDatabase(w http.ResponseWriter, r *http.Request, u *store.User) { id := r.PathValue("id") var req struct { Name *string `json:"name"` MergeStrategy *store.Strategy `json:"merge_strategy"` } if !readJSON(w, r, &req) { return } var out dbView err := s.st.Update(func(st *store.State) error { if err := adminsDatabase(st, u, id); err != nil { return err } db := st.Databases[id] if req.Name != nil { if strings.TrimSpace(*req.Name) == "" { return errors.New("name may not be empty") } db.Name = strings.TrimSpace(*req.Name) } if req.MergeStrategy != nil { if !req.MergeStrategy.Valid() { return errors.New("unknown merge strategy") } if req.MergeStrategy.NeedsKey() && db.WrappedKey == "" { return errors.New("merging needs the vault password: store it with " + "`keeporg db -id -store-password` first") } db.MergeStrategy = *req.MergeStrategy } out = toView(db, store.PermAdmin) return nil }) if err != nil { deny(w, err) return } s.notifyList(id) writeJSON(w, http.StatusOK, out) } // errConfirmName is the answer when an account with no password of its own // did not type the name of the thing it is about to destroy. var errConfirmName = errors.New("type the name exactly to confirm") // errNoDatabase is a database that does not exist or that the caller does // not administer; the two are not told apart, so neither answer says that a // database exists. var errNoDatabase = errors.New("no such database") // deleteDatabase removes a database, its grants and every stored version of // the file. Clients keep whatever they already have on disk: the server can // stop tracking a vault, but it has no business reaching into somebody's // home directory. // // It re-proves who the caller is first. Holding a session is enough to read // and write a vault; it is not enough to destroy every version of one, // because a session is what an unlocked laptop leaves lying around. The // password is checked like a sign-in — outside the store's lock and against // the attempt limits — and only for a caller who administers the database, // so it is not a password oracle for anyone else. // // An account that signs in only through an identity provider has no password // to type. Refusing those accounts would mean a deployment where nobody can // delete anything, so they confirm by typing the exact name instead. That is // a weaker gate and it is deliberately the fallback, not the rule. func (s *Server) deleteDatabase(r *http.Request, u *store.User, id, password, totpCode, typed string) (string, error) { var ( name string hasPassword bool ) s.st.Read(func(st *store.State) { if db := st.Databases[id]; db != nil && st.DatabasePerm(u.ID, id) == store.PermAdmin { name = db.Name } if cur := st.Users[u.ID]; cur != nil { hasPassword = cur.PassHash != "" } }) if name == "" { return "", errNoDatabase } var watchers []string remove := func(st *store.State, _ *store.User) error { db := st.Databases[id] if db == nil || st.DatabasePerm(u.ID, id) != store.PermAdmin { return errNoDatabase } name = db.Name // Taken before the delete: the people to tell are the ones who are // about to stop having access. watchers = st.Watchers(id) delete(st.Databases, id) st.Grants = filterGrants(st.Grants, func(g store.Grant) bool { return g.DatabaseID != id }) return nil } var err error if !hasPassword { if strings.TrimSpace(typed) != name { return "", errConfirmName } err = s.st.Update(func(st *store.State) error { return remove(st, u) }) } else { err = s.reconfirm(r, u, password, totpCode, remove) } if err != nil { return "", err } if ferr := s.blobs.Forget(id); ferr != nil { s.log.Printf("delete blobs for %s: %v", id, ferr) } s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: id}) s.log.Printf("%s deleted database %q (%s) and every stored version", u.Username, name, id) return name, nil } func (s *Server) handleDeleteDatabase(w http.ResponseWriter, r *http.Request, u *store.User) { var req struct { Password string `json:"password"` TOTP string `json:"totp"` Confirm string `json:"confirm_name"` } if r.ContentLength > 0 && !readJSON(w, r, &req) { return } if _, err := s.deleteDatabase(r, u, r.PathValue("id"), req.Password, req.TOTP, req.Confirm); err != nil { denyAuth(w, err) return } writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } // ---- groups and membership: API ---- type groupView struct { ID string `json:"id"` Name string `json:"name"` ParentID string `json:"parent_id,omitempty"` Admin bool `json:"admin"` Members int `json:"members"` } func (s *Server) handleListGroups(w http.ResponseWriter, r *http.Request, u *store.User) { out := []groupView{} s.st.Read(func(st *store.State) { counts := map[string]int{} for _, m := range st.Memberships { counts[m.GroupID]++ } for id := range visibleGroups(st, u) { g := st.Groups[id] out = append(out, groupView{ID: id, Name: g.Name, ParentID: g.ParentID, Admin: st.IsGroupAdmin(u.ID, id), Members: counts[id]}) } }) sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) writeJSON(w, http.StatusOK, map[string]any{"groups": out}) } func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request, u *store.User) { var req struct { Name string `json:"name"` ParentID string `json:"parent_id"` } if !readJSON(w, r, &req) { return } var out groupView err := s.st.Update(func(st *store.State) error { g, err := createGroup(st, u, req.Name, req.ParentID) if err != nil { return err } out = groupView{ID: g.ID, Name: g.Name, ParentID: g.ParentID, Admin: true} return nil }) if err != nil { deny(w, err) return } writeJSON(w, http.StatusCreated, out) } func (s *Server) handleDeleteGroup(w http.ResponseWriter, r *http.Request, u *store.User) { id := r.PathValue("id") if err := s.st.Update(func(st *store.State) error { return deleteGroup(st, u, id) }); err != nil { deny(w, err) return } writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } func (s *Server) handleSetMember(w http.ResponseWriter, r *http.Request, u *store.User) { groupID := r.PathValue("id") var req struct { Username string `json:"username"` UserID string `json:"user_id"` Role store.Role `json:"role"` } if !readJSON(w, r, &req) { return } if err := s.st.Update(func(st *store.State) error { return setMember(st, u, groupID, req.Role, req.UserID, req.Username) }); err != nil { deny(w, err) return } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request, u *store.User) { groupID, userRef := r.PathValue("id"), r.PathValue("user") if err := s.st.Update(func(st *store.State) error { return removeMember(st, u, groupID, userRef) }); err != nil { deny(w, err) return } writeJSON(w, http.StatusOK, map[string]string{"status": "removed"}) } // ---- grants: API ---- func (s *Server) handleSetGrant(w http.ResponseWriter, r *http.Request, u *store.User) { var req struct { GroupID string `json:"group_id"` DatabaseID string `json:"database_id"` Perm store.Perm `json:"perm"` } if !readJSON(w, r, &req) { return } var watchers []string err := s.st.Update(func(st *store.State) error { if err := setGrant(st, u, req.GroupID, req.DatabaseID, req.Perm); err != nil { return err } watchers = st.Watchers(req.DatabaseID) return nil }) if err != nil { deny(w, err) return } s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: req.DatabaseID}) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) handleDeleteGrant(w http.ResponseWriter, r *http.Request, u *store.User) { groupID := r.URL.Query().Get("group_id") dbID := r.URL.Query().Get("database_id") var watchers []string err := s.st.Update(func(st *store.State) error { // The watcher list is taken before the grant goes, because the people // losing access are exactly the ones who must be told. watchers = st.Watchers(dbID) return removeGrant(st, u, groupID, dbID) }) if err != nil { deny(w, err) return } s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: dbID}) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } // ---- invites: API ---- func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request, u *store.User) { var req struct { GroupID string `json:"group_id"` Role store.Role `json:"role"` TTLHours int `json:"ttl_hours"` MaxUses int `json:"max_uses"` } if !readJSON(w, r, &req) { return } var inv *store.Invite err := s.st.Update(func(st *store.State) error { var err error inv, err = createInvite(st, u, req.GroupID, req.Role, req.TTLHours, req.MaxUses) return err }) if err != nil { deny(w, err) return } writeJSON(w, http.StatusCreated, map[string]any{ "token": inv.Token, "url": s.inviteURL(inv.Token), "expires_at": inv.ExpiresAt, "max_uses": inv.MaxUses, "group_id": inv.GroupID, "role": inv.Role, }) } func (s *Server) handleListInvites(w http.ResponseWriter, r *http.Request, u *store.User) { type inviteView struct { Token string `json:"token"` URL string `json:"url"` GroupID string `json:"group_id,omitempty"` Role store.Role `json:"role"` Uses int `json:"uses"` MaxUses int `json:"max_uses"` ExpiresAt time.Time `json:"expires_at"` } out := []inviteView{} s.st.Read(func(st *store.State) { for _, inv := range st.Invites { if !mayManageInvite(st, u, inv) { continue } out = append(out, inviteView{Token: inv.Token, URL: s.inviteURL(inv.Token), GroupID: inv.GroupID, Role: inv.Role, Uses: inv.Uses, MaxUses: inv.MaxUses, ExpiresAt: inv.ExpiresAt}) } }) sort.Slice(out, func(i, j int) bool { return out[i].ExpiresAt.Before(out[j].ExpiresAt) }) writeJSON(w, http.StatusOK, map[string]any{"invites": out}) } func (s *Server) handleRevokeInvite(w http.ResponseWriter, r *http.Request, u *store.User) { if err := s.st.Update(func(st *store.State) error { return revokeInvite(st, u, r.PathValue("token")) }); err != nil { deny(w, err) return } writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"}) } // inviteURL is the link an invited person follows. func (s *Server) inviteURL(token string) string { return s.cfg.ExternalURL + "/invite/" + token } // ---- users: API ---- func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request, u *store.User) { out := []userView{} s.st.Read(func(st *store.State) { for _, target := range visibleUsers(st, u) { v := view(target) if !u.ServerAdmin && target.ID != u.ID { v.Email, v.TOTPEnabled, v.ServerAdmin = "", false, false } out = append(out, v) } }) writeJSON(w, http.StatusOK, map[string]any{"users": out}) } func (s *Server) handlePatchUser(w http.ResponseWriter, r *http.Request, u *store.User) { var req struct { Action accountAction `json:"action"` } if !readJSON(w, r, &req) { return } var out userView err := s.st.Update(func(st *store.State) error { target, err := adminAccount(st, u, r.PathValue("id"), req.Action) if err != nil { return err } out = view(target) return nil }) if err != nil { deny(w, err) return } writeJSON(w, http.StatusOK, out) } // notifyList tells everyone with access that the database list changed. func (s *Server) notifyList(dbID string) { var watchers []string s.st.Read(func(st *store.State) { watchers = st.Watchers(dbID) }) s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: dbID}) } func filterGrants(in []store.Grant, keep func(store.Grant) bool) []store.Grant { out := in[:0] for _, g := range in { if keep(g) { out = append(out, g) } } return out }