package server import ( "errors" "net/http" "sort" "strconv" "strings" "time" "cc.tiredsysadmin.keeporg/internal/store" ) // The administration pages. Running a KeepOrg server means inviting people, // putting them in groups and pointing groups at vaults; doing that over the // API from a terminal is fine for the person who built it and no use to // anyone else. These pages drive the same functions in admin.go that the API // does, so neither interface can grow a rule the other does not have. // errBadAction is the answer to a form that asked for something that does // not exist, which in practice means a hand-made request. var errBadAction = errors.New("unknown action") // ---- the page shell ---- type navItem struct { Label string Href string Current bool } // administersAnything reports whether the user administers any group, which // is what the administration pages are for. func administersAnything(st *store.State, u *store.User) bool { if u.ServerAdmin { return true } for _, m := range st.Memberships { if m.UserID == u.ID && m.Role == store.RoleAdmin && st.Groups[m.GroupID] != nil { return true } } return false } // shell is the data every signed-in page shares: who is looking, their CSRF // token, and the navigation they are entitled to. func (s *Server) shell(sess *webSession, title, current string) map[string]any { var admin bool s.st.Read(func(st *store.State) { admin = administersAnything(st, sess.user) }) nav := []navItem{{Label: "Vaults", Href: "/web/vaults"}} if admin { nav = append(nav, navItem{Label: "Groups", Href: "/web/groups"}, navItem{Label: "People", Href: "/web/users"}, navItem{Label: "Invites", Href: "/web/invites"}) } for i := range nav { nav[i].Current = nav[i].Label == current } return map[string]any{ "Title": title, "Wide": true, "User": sess.user.Username, "ServerAdmin": sess.user.ServerAdmin, "CSRF": sess.csrf, "Nav": nav, } } // webOption is one entry of a selector. type webOption struct { ID string Name string } // administeredGroups lists the groups the user may put things in. func administeredGroups(st *store.State, u *store.User) []webOption { out := []webOption{} for id, g := range st.Groups { if st.IsGroupAdmin(u.ID, id) { out = append(out, webOption{ID: id, Name: g.Name}) } } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } // ---- groups ---- type webGroupRow struct { ID string Name string Pad int Members int Owns int Admin bool } // groupTree lays the visible groups out depth first, so a child always // follows its parent and the indentation shows the shape of the tree. A // group whose parent is hidden from this user is shown as a root: the // hierarchy above it is not theirs to see. func groupTree(st *store.State, u *store.User) []webGroupRow { vis := visibleGroups(st, u) members := map[string]int{} for _, m := range st.Memberships { members[m.GroupID]++ } owns := map[string]int{} for _, d := range st.Databases { owns[d.OwnerGroupID]++ } children := map[string][]string{} roots := []string{} for id := range vis { if p := st.Groups[id].ParentID; p != "" && vis[p] { children[p] = append(children[p], id) } else { roots = append(roots, id) } } byName := func(ids []string) { sort.Slice(ids, func(i, j int) bool { return st.Groups[ids[i]].Name < st.Groups[ids[j]].Name }) } out := []webGroupRow{} var walk func(ids []string, depth int) walk = func(ids []string, depth int) { byName(ids) for _, id := range ids { g := st.Groups[id] out = append(out, webGroupRow{ID: id, Name: g.Name, Pad: 8 + depth*20, Members: members[id], Owns: owns[id], Admin: st.IsGroupAdmin(u.ID, id)}) walk(children[id], depth+1) } } walk(roots, 0) return out } type webMemberRow struct { ID string Username string Role string Disabled bool } // webAccessRow is one vault this group reaches. Via names the group the // grant actually hangs on when it is inherited from above. type webAccessRow struct { DatabaseID string Name string Perm string Via string Owned bool } type webGroupDetail struct { ID string Name string Parent string Admin bool Members []webMemberRow Access []webAccessRow Children []webGroupRow Vaults []webOption People []webOption } // groupDetail gathers one group's members and the vaults it reaches, or nil // when the group does not exist or is not this user's business. func groupDetail(st *store.State, u *store.User, id string) *webGroupDetail { g := st.Groups[id] if g == nil || !visibleGroups(st, u)[id] { return nil } d := &webGroupDetail{ID: id, Name: g.Name, Admin: st.IsGroupAdmin(u.ID, id), Members: []webMemberRow{}, Access: []webAccessRow{}, Children: []webGroupRow{}, Vaults: []webOption{}, People: []webOption{}} if p := st.Groups[g.ParentID]; p != nil { d.Parent = p.Name } for _, m := range st.Memberships { if m.GroupID != id { continue } if target := st.Users[m.UserID]; target != nil { d.Members = append(d.Members, webMemberRow{ID: target.ID, Username: target.Username, Role: string(m.Role), Disabled: target.Disabled}) } } sort.Slice(d.Members, func(i, j int) bool { return d.Members[i].Username < d.Members[j].Username }) // A grant on an ancestor reaches this group too, and an administrator who // cannot see that will keep wondering why a member has access. for _, anc := range st.Ancestors(id) { for _, gr := range st.Grants { if gr.GroupID != anc { continue } db := st.Databases[gr.DatabaseID] if db == nil { continue } row := webAccessRow{DatabaseID: db.ID, Name: db.Name, Perm: string(gr.Perm)} if anc != id { row.Via = st.Groups[anc].Name } d.Access = append(d.Access, row) } } for _, db := range st.Databases { if db.OwnerGroupID == id { d.Access = append(d.Access, webAccessRow{DatabaseID: db.ID, Name: db.Name, Perm: "admin", Owned: true}) } } sort.Slice(d.Access, func(i, j int) bool { return d.Access[i].Name < d.Access[j].Name }) for _, row := range groupTree(st, u) { if st.Groups[row.ID].ParentID == id { row.Pad = 8 d.Children = append(d.Children, row) } } if d.Admin { for dbID, db := range st.Databases { if st.DatabasePerm(u.ID, dbID) == store.PermAdmin { d.Vaults = append(d.Vaults, webOption{ID: dbID, Name: db.Name}) } } sort.Slice(d.Vaults, func(i, j int) bool { return d.Vaults[i].Name < d.Vaults[j].Name }) for _, target := range visibleUsers(st, u) { d.People = append(d.People, webOption{ID: target.ID, Name: target.Username}) } } return d } func (s *Server) handleWebGroups(w http.ResponseWriter, r *http.Request, sess *webSession) { s.renderGroups(w, sess, "", "") } func (s *Server) handleWebCreateGroup(w http.ResponseWriter, r *http.Request, sess *webSession) { var name string err := s.st.Update(func(st *store.State) error { g, err := createGroup(st, sess.user, r.FormValue("name"), r.FormValue("parent")) if err != nil { return err } name = g.Name return nil }) if err != nil { s.renderGroups(w, sess, humanError(err), "") return } s.renderGroups(w, sess, "", "Created the group "+name+".") } func (s *Server) handleWebGroup(w http.ResponseWriter, r *http.Request, sess *webSession) { s.renderGroup(w, sess, r.PathValue("id"), "", "") } // handleWebGroupAction runs every form on a group's page. One route keeps the // CSRF check and the permission check in one place for all of them. func (s *Server) handleWebGroupAction(w http.ResponseWriter, r *http.Request, sess *webSession) { id := r.PathValue("id") action := r.FormValue("action") var notice string err := s.st.Update(func(st *store.State) error { switch action { case "member": who := strings.TrimSpace(r.FormValue("user")) if err := setMember(st, sess.user, id, store.Role(r.FormValue("role")), who); err != nil { return err } notice = who + " is now a " + r.FormValue("role") + " of this group." case "member-remove": who := r.FormValue("user") if err := removeMember(st, sess.user, id, who); err != nil { return err } if target := st.Users[who]; target != nil { who = target.Username } notice = who + " was removed from this group." case "grant": dbID := r.FormValue("database") if err := setGrant(st, sess.user, id, dbID, store.Perm(r.FormValue("perm"))); err != nil { return err } if db := st.Databases[dbID]; db != nil { notice = "This group now has " + r.FormValue("perm") + " access to " + db.Name + "." } case "grant-remove": dbID := r.FormValue("database") if err := removeGrant(st, sess.user, id, dbID); err != nil { return err } if db := st.Databases[dbID]; db != nil { notice = "This group no longer has access to " + db.Name + "." } case "delete": return deleteGroup(st, sess.user, id) default: return errBadAction } return nil }) switch { case err != nil: s.renderGroup(w, sess, id, humanError(err), "") case action == "delete": http.Redirect(w, r, "/web/groups", http.StatusFound) default: // Anyone who gained or lost a vault needs to hear about it now, // otherwise their client only notices at the next sync. if strings.HasPrefix(action, "grant") { s.notifyList(r.FormValue("database")) } s.renderGroup(w, sess, id, "", notice) } } // ---- people ---- type webUserRow struct { ID string Username string Email string SignIn string Groups string TOTP bool ServerAdmin bool Disabled bool Self bool } func userRows(st *store.State, u *store.User) []webUserRow { groups := map[string][]string{} for _, m := range st.Memberships { g := st.Groups[m.GroupID] if g == nil { continue } label := g.Name if m.Role == store.RoleAdmin { label += " (admin)" } groups[m.UserID] = append(groups[m.UserID], label) } out := []webUserRow{} for _, target := range visibleUsers(st, u) { mine := groups[target.ID] sort.Strings(mine) signIn := "no way in" switch { case target.PassHash != "" && target.OIDCSubject != "": signIn = "password, single sign-on" case target.PassHash != "": signIn = "password" case target.OIDCSubject != "": signIn = "single sign-on" } out = append(out, webUserRow{ID: target.ID, Username: target.Username, Email: target.Email, SignIn: signIn, Groups: strings.Join(mine, ", "), TOTP: target.TOTPConfirmed, ServerAdmin: target.ServerAdmin, Disabled: target.Disabled, Self: target.ID == u.ID}) } return out } func (s *Server) handleWebUsers(w http.ResponseWriter, r *http.Request, sess *webSession) { s.renderUsers(w, sess, "", "") } func (s *Server) handleWebUserAction(w http.ResponseWriter, r *http.Request, sess *webSession) { act := accountAction(r.FormValue("action")) var notice string err := s.st.Update(func(st *store.State) error { target, err := adminAccount(st, sess.user, r.FormValue("user"), act) if err != nil { return err } notice = accountNotice(target.Username, act) return nil }) if err != nil { s.renderUsers(w, sess, humanError(err), "") return } s.renderUsers(w, sess, "", notice) } func accountNotice(username string, act accountAction) string { switch act { case accountDisable: return username + " is disabled and signed out everywhere." case accountEnable: return username + " can sign in again." case accountPromote: return username + " is now a server administrator." case accountDemote: return username + " is no longer a server administrator." case accountClearTOTP: return username + " can sign in without a one-time code and should enrol a new one." case accountDelete: return username + " was deleted, along with their group memberships and sessions." } return "Done." } // ---- invites ---- type webInviteRow struct { Token string URL string Group string Role string Uses int Max string Expires string Expired bool By string } func (s *Server) inviteRows(st *store.State, u *store.User) []webInviteRow { now := time.Now() out := []webInviteRow{} for _, inv := range st.Invites { if !mayManageInvite(st, u, inv) { continue } row := webInviteRow{Token: inv.Token, URL: s.inviteURL(inv.Token), Group: "no group", Role: string(inv.Role), Uses: inv.Uses, Max: strconv.Itoa(inv.MaxUses), Expires: inv.ExpiresAt.UTC().Format("2006-01-02 15:04 UTC"), Expired: !inv.Usable(now)} if g := st.Groups[inv.GroupID]; g != nil { row.Group = g.Name } if inv.MaxUses <= 0 { row.Max = "unlimited" } if by := st.Users[inv.CreatedBy]; by != nil { row.By = by.Username } out = append(out, row) } sort.Slice(out, func(i, j int) bool { return out[i].Expires < out[j].Expires }) return out } func (s *Server) handleWebInvites(w http.ResponseWriter, r *http.Request, sess *webSession) { s.renderInvites(w, sess, "", "") } func (s *Server) handleWebInviteAction(w http.ResponseWriter, r *http.Request, sess *webSession) { var notice string err := s.st.Update(func(st *store.State) error { switch r.FormValue("action") { case "revoke": if err := revokeInvite(st, sess.user, r.FormValue("token")); err != nil { return err } notice = "That invite link no longer works." return nil case "create": ttl, _ := strconv.Atoi(r.FormValue("ttl")) uses, _ := strconv.Atoi(r.FormValue("uses")) inv, err := createInvite(st, sess.user, r.FormValue("group"), store.Role(r.FormValue("role")), ttl, uses) if err != nil { return err } notice = "Invite created: " + s.inviteURL(inv.Token) + " — send this link to the person joining." return nil } return errBadAction }) if err != nil { s.renderInvites(w, sess, humanError(err), "") return } s.renderInvites(w, sess, "", notice) }