diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 2709f6c..d23e8b5 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -29,7 +29,8 @@ cmd/keeporg/ desktop client: login, run, sync, status, administration internal/store/ domain model, JSON metadata store, ACL resolution internal/blob/ versioned KDBX payload storage on disk internal/kdbx/ KDBX 4 codec and the merge engine, with its own crypto -internal/server/ HTTP surface: auth, OIDC, sync, admin, SSE hub, web, pages +internal/server/ HTTP surface: auth, OIDC, sync, admin, SSE hub, web, + web administration, page templates internal/client/ shared client engine: API wrapper, merge policy, mirror internal/secret/ password verifiers, tokens, identifiers internal/totp/ RFC 6238 second factor @@ -343,18 +344,54 @@ expected request sequence. ## The web interface -The server has one browsable page set, at `/web`, and it exists for the one -job a command line does badly: creating a vault and setting its password. -Sign in, see which vaults you can reach, create one. It never shows a vault's -contents — the server opens a vault only to merge it — and there is no -entry browsing, by design. +`/web` is the browsable half of the server. It covers what a command line +does badly — creating a vault and setting its password, and the daily +administration of people — and nothing else. It never shows a vault's +contents: the server opens a vault only to merge it, and there is no entry +browsing, by design. + +| Page | What it does | +| --- | --- | +| `/web/vaults` | the vaults you can reach, the create-a-vault form, your own account password | +| `/web/groups` | the group tree you can see, and a form to add a group inside one you administer | +| `/web/groups/{id}` | one group: members and their roles, the vaults it reaches and where that access comes from, grants given and revoked, deletion | +| `/web/users` | the accounts you may see; for a server administrator, disable, enable, clear the second factor, promote, demote, delete | +| `/web/invites` | outstanding invite links with their use counts and expiry, a form to make one, a button to revoke one | + +The navigation offers the last three only to somebody who administers a +group; a plain member sees their vaults and nothing else. The group selector +only offers groups the signed-in user administers, and a user who +administers none is told so instead of being shown a form that would fail. + +**One set of rules behind both interfaces.** Every administrative change is +a function in `internal/server/admin.go` taking the state and the caller — +`createGroup`, `setMember`, `setGrant`, `createInvite`, `revokeInvite`, +`adminAccount` and the rest. The JSON handlers and the HTML forms are both +thin wrappers over them. Had the pages re-implemented the checks, the two +interfaces would have drifted apart on the first change, and that drift would +have been a permission bug rather than a cosmetic one. + +**Who may do what.** Group administration follows the tree: administering a +group means administering everything inside it, so the member and grant forms +appear only on groups you administer, and their selectors only offer vaults +you administer. Account changes are deliberately not part of that. They are +server administrator only: a group administrator decides who reaches which +vault, which is access, while disabling an account, clearing somebody's +second factor or deleting them reaches past the group they happen to share. +The three changes that would lock the caller out — disable, demote, delete on +their own account — are refused outright, because a server with no reachable +administrator can only be repaired with `keeporgd -reset-admin` on the host. Browser sessions are the same session tokens the API uses, carried in a cookie that is `HttpOnly`, `SameSite=Strict`, `Path=/`, and `Secure` whenever -`external_url` is https. Every form carries a per-session CSRF token that is -checked on each POST. The group selector only offers groups the signed-in -user administers, and a user who administers none is told so instead of being -shown a form that would fail. +`external_url` is https. Every page is served through `webPage` and every +form through `webForm`: they resolve the cookie, and a form additionally has +its per-session CSRF token checked before the handler runs. A new form cannot +forget that check, because the check is not in the handler. + +No page carries any JavaScript. Each control is its own small form with the +CSRF token and one action in it, which is what makes the interface work in a +locked-down browser and keeps its attack surface down to that token. Creating a vault is one transaction: the server builds an empty KDBX 4 database, encrypts it with the password from the form, stores it as version @@ -484,8 +521,9 @@ keeporg invite -group -role member -uses 5 -hours 72 keeporg grant -group -db -perm read ``` -Or create the vault at `https://keeporg.example.com/web`, which is the same -operation with a form around it. +Or do all of it at `https://keeporg.example.com/web`: `/web/groups` builds +the tree and fills it, `/web/invites` issues the links, `/web/vaults` creates +the vault. The pages call the same functions these commands call. `keeporg vault` creates the KDBX file itself, so the vault has content from the start: it appears in every member's mirror directory and opens with the diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 55306cf..b23aa29 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -121,10 +121,19 @@ the group named or any group above it. | DELETE | `/api/v1/grants` | `?group_id=&database_id=` | database admin | | POST | `/api/v1/invites` | `{group_id, role, ttl_hours, max_uses}` | group admin | | GET | `/api/v1/invites` | – | your own invites, and those of groups you administer | +| DELETE | `/api/v1/invites/{token}` | – | whoever may see it: its creator, an admin of its group, a server admin | | GET | `/api/v1/users` | – | yourself, plus members of groups you administer | +| PATCH | `/api/v1/users/{id}` | `{action}` | server admin | | GET | `/healthz` | – | unauthenticated liveness | `merge_strategy` is one of `merge`, `server-wins`, `client-wins`, `manual`. Switching to `merge` requires a stored key and is otherwise refused with `400`. `perm` is one of `read`, `write`, `admin`. `role` is `member` or `admin`. + +An account `action` is one of `disable`, `enable`, `promote`, `demote`, +`clear-totp` or `delete`. `disable` and `delete` also revoke every session +that account holds. The three that would lock the caller out — `disable`, +`demote`, `delete` — are refused on the caller's own account, because a +server with no reachable administrator can only be repaired with +`keeporgd -reset-admin` on the host. diff --git a/internal/server/admin.go b/internal/server/admin.go index cb61df7..2b7e426 100644 --- a/internal/server/admin.go +++ b/internal/server/admin.go @@ -13,6 +13,12 @@ import ( 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. + // deny maps a domain error onto a response. func deny(w http.ResponseWriter, err error) { switch { @@ -25,6 +31,344 @@ func deny(w http.ResponseWriter, 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 ---- + +func setGrant(st *store.State, u *store.User, groupID, dbID string, perm store.Perm) error { + if st.Databases[dbID] == nil { + return errors.New("no such database") + } + if st.Groups[groupID] == nil { + return errors.New("no such group") + } + if st.DatabasePerm(u.ID, dbID) != store.PermAdmin { + return errForbidden + } + 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 st.Databases[dbID] == nil { + return errors.New("no such database") + } + if st.DatabasePerm(u.ID, dbID) != store.PermAdmin { + return errForbidden + } + 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" + 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 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) { @@ -139,7 +483,7 @@ func (s *Server) handleDeleteDatabase(w http.ResponseWriter, r *http.Request, u writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } -// ---- groups and membership ---- +// ---- groups and membership: API ---- type groupView struct { ID string `json:"id"` @@ -156,23 +500,10 @@ func (s *Server) handleListGroups(w http.ResponseWriter, r *http.Request, u *sto for _, m := range st.Memberships { counts[m.GroupID]++ } - reach := st.EffectiveGroups(u.ID) - for id, g := range st.Groups { - admin := st.IsGroupAdmin(u.ID, id) - visible := admin || reach[id] || u.ServerAdmin - if !visible { - // A group below one the user administers is visible too. - for _, anc := range st.Ancestors(id) { - if st.IsGroupAdmin(u.ID, anc) { - visible = true - break - } - } - } - if visible { - out = append(out, groupView{ID: id, Name: g.Name, ParentID: g.ParentID, - Admin: admin, Members: counts[id]}) - } + 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 }) @@ -189,24 +520,10 @@ func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request, u *st } var out groupView err := s.st.Update(func(st *store.State) error { - if strings.TrimSpace(req.Name) == "" { - return errors.New("name is required") + g, err := createGroup(st, u, req.Name, req.ParentID) + if err != nil { + return err } - if req.ParentID == "" { - if !u.ServerAdmin { - return errForbidden - } - } else { - if st.Groups[req.ParentID] == nil { - return errors.New("unknown parent group") - } - if !st.IsGroupAdmin(u.ID, req.ParentID) { - return errForbidden - } - } - g := &store.Group{ID: secret.ID(), Name: strings.TrimSpace(req.Name), - ParentID: req.ParentID, CreatedAt: time.Now()} - st.Groups[g.ID] = g out = groupView{ID: g.ID, Name: g.Name, ParentID: g.ParentID, Admin: true} return nil }) @@ -219,32 +536,9 @@ func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request, u *st func (s *Server) handleDeleteGroup(w http.ResponseWriter, r *http.Request, u *store.User) { id := r.PathValue("id") - err := s.st.Update(func(st *store.State) error { - g := st.Groups[id] - if g == 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 - }) - if err != nil { + if err := s.st.Update(func(st *store.State) error { + return deleteGroup(st, u, id) + }); err != nil { deny(w, err) return } @@ -261,37 +555,9 @@ func (s *Server) handleSetMember(w http.ResponseWriter, r *http.Request, u *stor if !readJSON(w, r, &req) { return } - err := s.st.Update(func(st *store.State) error { - if st.Groups[groupID] == nil { - return errors.New("no such group") - } - if !st.IsGroupAdmin(u.ID, groupID) { - return errForbidden - } - target := st.Users[req.UserID] - if target == nil && req.Username != "" { - target = st.UserByName(req.Username) - } - if target == nil { - return errors.New("no such user") - } - role := req.Role - 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 - }) - if err != nil { + 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 } @@ -300,38 +566,16 @@ func (s *Server) handleSetMember(w http.ResponseWriter, r *http.Request, u *stor func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request, u *store.User) { groupID, userRef := r.PathValue("id"), r.PathValue("user") - err := s.st.Update(func(st *store.State) error { - if st.Groups[groupID] == nil { - return errors.New("no such group") - } - if !st.IsGroupAdmin(u.ID, groupID) { - return errForbidden - } - target := st.Users[userRef] - if target == nil { - target = st.UserByName(userRef) - } - 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 - }) - if err != nil { + 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 ---- +// ---- grants: API ---- func (s *Server) handleSetGrant(w http.ResponseWriter, r *http.Request, u *store.User) { var req struct { @@ -344,27 +588,9 @@ func (s *Server) handleSetGrant(w http.ResponseWriter, r *http.Request, u *store } var watchers []string err := s.st.Update(func(st *store.State) error { - if st.Databases[req.DatabaseID] == nil { - return errors.New("no such database") + if err := setGrant(st, u, req.GroupID, req.DatabaseID, req.Perm); err != nil { + return err } - if st.Groups[req.GroupID] == nil { - return errors.New("no such group") - } - if st.DatabasePerm(u.ID, req.DatabaseID) != store.PermAdmin { - return errForbidden - } - if !req.Perm.Valid() { - return errors.New("perm must be read, write or admin") - } - for i := range st.Grants { - if st.Grants[i].GroupID == req.GroupID && st.Grants[i].DatabaseID == req.DatabaseID { - st.Grants[i].Perm = req.Perm - watchers = st.Watchers(req.DatabaseID) - return nil - } - } - st.Grants = append(st.Grants, store.Grant{GroupID: req.GroupID, - DatabaseID: req.DatabaseID, Perm: req.Perm}) watchers = st.Watchers(req.DatabaseID) return nil }) @@ -381,17 +607,10 @@ func (s *Server) handleDeleteGrant(w http.ResponseWriter, r *http.Request, u *st dbID := r.URL.Query().Get("database_id") var watchers []string err := s.st.Update(func(st *store.State) error { - if st.Databases[dbID] == nil { - return errors.New("no such database") - } - if st.DatabasePerm(u.ID, dbID) != store.PermAdmin { - return errForbidden - } + // 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) - st.Grants = filterGrants(st.Grants, func(g store.Grant) bool { - return g.GroupID != groupID || g.DatabaseID != dbID - }) - return nil + return removeGrant(st, u, groupID, dbID) }) if err != nil { deny(w, err) @@ -401,7 +620,7 @@ func (s *Server) handleDeleteGrant(w http.ResponseWriter, r *http.Request, u *st writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } -// ---- invites ---- +// ---- invites: API ---- func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request, u *store.User) { var req struct { @@ -413,36 +632,11 @@ func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request, u *s if !readJSON(w, r, &req) { return } - inv := &store.Invite{Token: secret.Token(), GroupID: req.GroupID, Role: req.Role, - CreatedBy: u.ID, CreatedAt: time.Now(), MaxUses: req.MaxUses} - if inv.Role == "" { - inv.Role = store.RoleMember - } - if req.TTLHours <= 0 { - req.TTLHours = 168 - } - if inv.MaxUses == 0 { - inv.MaxUses = 1 - } - inv.ExpiresAt = time.Now().Add(time.Duration(req.TTLHours) * time.Hour) + var inv *store.Invite err := s.st.Update(func(st *store.State) error { - if inv.GroupID == "" { - if !u.ServerAdmin { - return errors.New("group_id is required") - } - } else { - if st.Groups[inv.GroupID] == nil { - return errors.New("unknown group") - } - if !st.IsGroupAdmin(u.ID, inv.GroupID) { - return errForbidden - } - } - if !inv.Role.Valid() { - return errors.New("role must be member or admin") - } - st.Invites[inv.Token] = inv - return nil + var err error + inv, err = createInvite(st, u, req.GroupID, req.Role, req.TTLHours, req.MaxUses) + return err }) if err != nil { deny(w, err) @@ -450,7 +644,7 @@ func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request, u *s } writeJSON(w, http.StatusCreated, map[string]any{ "token": inv.Token, - "url": s.cfg.ExternalURL + "/invite/" + inv.Token, + "url": s.inviteURL(inv.Token), "expires_at": inv.ExpiresAt, "max_uses": inv.MaxUses, "group_id": inv.GroupID, @@ -471,11 +665,10 @@ func (s *Server) handleListInvites(w http.ResponseWriter, r *http.Request, u *st out := []inviteView{} s.st.Read(func(st *store.State) { for _, inv := range st.Invites { - if !u.ServerAdmin && inv.CreatedBy != u.ID && - !(inv.GroupID != "" && st.IsGroupAdmin(u.ID, inv.GroupID)) { + if !mayManageInvite(st, u, inv) { continue } - out = append(out, inviteView{Token: inv.Token, URL: s.cfg.ExternalURL + "/invite/" + inv.Token, + 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}) } @@ -484,30 +677,54 @@ func (s *Server) handleListInvites(w http.ResponseWriter, r *http.Request, u *st writeJSON(w, http.StatusOK, map[string]any{"invites": out}) } -// ---- users ---- +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 id, target := range st.Users { - if u.ServerAdmin || id == u.ID || s.sharesAdministeredGroup(st, u.ID, id) { - out = append(out, view(target)) - } + for _, target := range visibleUsers(st, u) { + out = append(out, view(target)) } }) - sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username }) writeJSON(w, http.StatusOK, map[string]any{"users": out}) } -// sharesAdministeredGroup reports whether admin administers any group the -// target belongs to, which is what makes the target visible to them. -func (s *Server) sharesAdministeredGroup(st *store.State, adminID, targetID string) bool { - for _, m := range st.Memberships { - if m.UserID == targetID && st.IsGroupAdmin(adminID, m.GroupID) { - return true - } +func (s *Server) handlePatchUser(w http.ResponseWriter, r *http.Request, u *store.User) { + var req struct { + Action accountAction `json:"action"` } - return false + 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. diff --git a/internal/server/pages.go b/internal/server/pages.go index 4ade4b2..cd8fbc9 100644 --- a/internal/server/pages.go +++ b/internal/server/pages.go @@ -5,9 +5,12 @@ import ( "net/http" ) -// The server has no browsable UI. These three pages exist only because a -// browser is the right place to approve a device, redeem an invite and run an -// OIDC round trip. +// The pages: sign-in, the vault list, the administration pages in +// pagesadmin.go, and the three one-shot pages a browser is the right place +// for — approving a device, redeeming an invite and finishing an OIDC round +// trip. There is no JavaScript anywhere in them: every control is a form, +// which is what makes the whole interface work in a locked-down browser and +// keeps the security surface down to the CSRF token. var pages = template.Must(template.New("layout").Parse(` {{define "layout"}} @@ -17,10 +20,11 @@ var pages = template.Must(template.New("layout").Parse(` :root{color-scheme:light dark;--fg:#1a1a1a;--bg:#fafafa;--card:#fff;--line:#d8d8d8;--accent:#2d6a4f} @media(prefers-color-scheme:dark){:root{--fg:#e8e8e8;--bg:#161616;--card:#1f1f1f;--line:#3a3a3a;--accent:#74c69d}} *{box-sizing:border-box} -body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--bg);color:var(--fg); +body{margin:0;min-height:100vh;display:grid;grid-template-columns:minmax(0,1fr);place-items:center; +background:var(--bg);color:var(--fg); font:16px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;padding:24px} body.wide{place-items:start center} -main{width:100%;max-width:26rem;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:28px} +main{width:100%;min-width:0;max-width:26rem;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:28px} body.wide main{max-width:54rem} h1{font-size:1.25rem;margin:0 0 .25rem} p.sub{margin:0 0 1.25rem;opacity:.7;font-size:.9rem} @@ -56,7 +60,29 @@ border:1px solid var(--line);border-radius:8px} fieldset{border:1px solid var(--line);border-radius:10px;padding:1rem 1.2rem 1.4rem;margin:0} legend{padding:0 .4rem;font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;opacity:.7} .empty{opacity:.7;font-size:.9rem;margin:.5rem 0 1.5rem} -
{{template "body" .}}
{{end}} +nav{display:flex;gap:.25rem;flex-wrap:wrap;margin:0 0 1.25rem;padding-bottom:.75rem; +border-bottom:1px solid var(--line)} +nav a,nav .here{padding:.3rem .6rem;border-radius:7px;font-size:.9rem;text-decoration:none;color:var(--accent)} +nav .here{background:var(--accent);color:#fff;font-weight:600} +nav form.out{margin:0 0 0 auto} +nav button{width:auto;margin:0;padding:.3rem .6rem;font:inherit;font-size:.85rem;font-weight:500; +background:transparent;color:var(--accent);border:1px solid var(--line)} +a{color:var(--accent)} +td form{display:inline;margin:0} +td button{width:auto;margin:0 .25rem .25rem 0;padding:.3rem .5rem;font-size:.8rem;font-weight:500; +background:transparent;color:var(--accent);border:1px solid var(--line)} +td button.danger{color:#b02a37} +@media(prefers-color-scheme:dark){td button.danger{color:#f2b8c0}} +.inline{display:flex;gap:.6rem;align-items:flex-end;flex-wrap:wrap;margin:0} +.inline>div{flex:1 1 9rem} +.inline label{margin-top:0} +.inline button{width:auto;margin:0;padding:.6rem 1.1rem} +input[readonly]{min-width:17rem;font-family:ui-monospace,SFMono-Regular,monospace; +font-size:.8rem;opacity:.85} +.scroll{max-width:100%;overflow-x:auto} +fieldset+fieldset{margin-top:1.5rem} +.gone{opacity:.55} +
{{if .Nav}}{{end}}{{template "body" .}}
{{end}} `)) var deviceTmpl = template.Must(template.Must(pages.Clone()).Parse(` @@ -101,17 +127,12 @@ var signInTmpl = template.Must(template.Must(pages.Clone()).Parse(` var vaultsTmpl = template.Must(template.Must(pages.Clone()).Parse(` {{define "body"}} -
-

Your vaults

{{.User}}

-
- - -
-
+

Your vaults

+

The files your KeepOrg client keeps in sync.

{{if .Error}}

{{.Error}}

{{end}} {{if .Notice}}

{{.Notice}}

{{end}} {{if .Vaults}} - +
{{range .Vaults}} @@ -121,7 +142,7 @@ var vaultsTmpl = template.Must(template.Must(pages.Clone()).Parse(` {{end}} -
VaultAccessOn conflictVersionLast saved
{{.Version}} {{.Updated}}{{if .By}}
by {{.By}}{{end}}
+ {{else}}

No vaults yet. Create one below, then open it from the KeepOrg client on your computer or phone.

@@ -201,10 +222,9 @@ func (s *Server) renderSignIn(w http.ResponseWriter, errMsg string) { func (s *Server) renderVaults(w http.ResponseWriter, sess *webSession, errMsg, notice string) { vaults, groups := s.vaultRows(sess.user) - render(w, vaultsTmpl, map[string]any{ - "Title": "Vaults", "Wide": true, "User": sess.user.Username, "CSRF": sess.csrf, - "Vaults": vaults, "Groups": groups, "Error": errMsg, "Notice": notice, - }) + d := s.shell(sess, "Vaults", "Vaults") + d["Vaults"], d["Groups"], d["Error"], d["Notice"] = vaults, groups, errMsg, notice + render(w, vaultsTmpl, d) } func (s *Server) renderDone(w http.ResponseWriter, title, msg string) { diff --git a/internal/server/pagesadmin.go b/internal/server/pagesadmin.go new file mode 100644 index 0000000..15ab76f --- /dev/null +++ b/internal/server/pagesadmin.go @@ -0,0 +1,351 @@ +package server + +import ( + "html/template" + "net/http" + + "cc.tiredsysadmin.keeporg/internal/store" +) + +// The administration pages. Each control is its own small form carrying the +// session's CSRF token and one action, which is why there is no JavaScript +// here and no way to act on a page you were not served. + +var groupsTmpl = template.Must(template.Must(pages.Clone()).Parse(` +{{define "body"}} +

Groups

+

Nested groups decide who reaches which vault.

+{{if .Error}}

{{.Error}}

{{end}} +{{if .Notice}}

{{.Notice}}

{{end}} +{{if .Groups}} +
+ + +{{range .Groups}} + + + + +{{end}} +
GroupPeopleVaults
{{.Name}}{{.Members}}{{.Owns}}{{if .Admin}}you administer{{end}}
+{{else}} +

There are no groups you can see.

+{{end}} + +{{if or .Parents .ServerAdmin}} +
New group +
+ +
+
+
+
+ +
+

Access flows downwards: a vault granted to a group is reachable by +every group inside it, and whoever administers a group administers everything +inside it too.

+
+{{end}} +{{end}}`)) + +var groupTmpl = template.Must(template.Must(pages.Clone()).Parse(` +{{define "body"}} +{{with .G}} +

{{.Name}}

+

{{if .Parent}}Inside {{.Parent}}. {{end}}All groups

+{{end}} +{{if .Error}}

{{.Error}}

{{end}} +{{if .Notice}}

{{.Notice}}

{{end}} +{{with .G}} +

People

+{{if .Members}} +
+ + +{{range .Members}} + + + +{{end}} +
WhoRole
{{.Username}}{{if .Disabled}} disabled{{end}}{{.Role}}{{if $.G.Admin}} +
+ + + + + +
+
+ + + + +
+ {{end}}
+{{else}}

Nobody is in this group yet.

{{end}} + +{{if .Admin}} +
Add somebody +
+ + +
+ +{{range .People}}
+
+
+ +
+

Only accounts that already exist can be added. To bring in somebody +new, send them an invite link for this group.

+
+{{end}} + +

Vaults this group reaches

+{{if .Access}} +
+ + +{{range .Access}} + + + + +{{end}} +
VaultAccessWhere from
{{.Name}}.kdbx{{.Perm}}{{if .Owned}}owns it{{else if .Via}}granted to {{.Via}}{{else}}granted here{{end}}{{if and $.G.Admin (not .Owned) (not .Via)}} +
+ + + + +
+ {{end}}
+{{else}}

This group reaches no vaults.

{{end}} + +{{if and .Admin .Vaults}} +
Grant access +
+ + +
+
+
+
+ +
+
+{{end}} + +{{if .Children}} +

Groups inside this one

+
+{{range .Children}} + + +{{end}} +
{{.Name}}{{.Members}} people{{.Owns}} vaults
+{{end}} + +{{if .Admin}} +
+Delete this group +

A group can only go once it has no members, no child groups and owns +no vaults. Deleting it also drops the grants that hang on it.

+
+ + + +
+
+{{end}} +{{end}} +{{end}}`)) + +var usersTmpl = template.Must(template.Must(pages.Clone()).Parse(` +{{define "body"}} +

People

+

{{if .ServerAdmin}}Every account on this server.{{else}}The people in the +groups you administer.{{end}}

+{{if .Error}}

{{.Error}}

{{end}} +{{if .Notice}}

{{.Notice}}

{{end}} +
+{{if .ServerAdmin}}{{end}} + +{{range .Users}} + + + + {{if $.ServerAdmin}}{{end}} +{{end}} +
WhoSigns in withGroups
{{.Username}} + {{if .ServerAdmin}}server admin{{end}} + {{if .Disabled}}disabled{{end}} + {{if .Email}}
{{.Email}}{{end}}
{{.SignIn}}{{if .TOTP}}
one-time code{{end}}
{{if .Groups}}{{.Groups}}{{else}}none{{end}} + {{if .Disabled}} +
+ + + + +
+ {{else if not .Self}} +
+ + + + +
+ {{end}} + {{if .TOTP}} +
+ + + + +
+ {{end}} + {{if not .Self}} +
+ + + + +
+
+ + + + +
+ {{end}} +
+{{if .ServerAdmin}} +

Disabling an account signs it out everywhere and keeps its history; +deleting it removes the account and its group memberships. Clearing the second +factor is what you do when somebody loses their phone — they can sign in with +their password alone until they enrol a new one.

+{{else}} +

Only a server administrator can change an account. You decide who +is in your groups, from each group's page.

+{{end}} +{{end}}`)) + +var invitesTmpl = template.Must(template.Must(pages.Clone()).Parse(` +{{define "body"}} +

Invites

+

An invite link lets somebody create their own account and lands them +in one group.

+{{if .Error}}

{{.Error}}

{{end}} +{{if .Notice}}

{{.Notice}}

{{end}} +{{if .Invites}} +
+ + +{{range .Invites}} + + + + + +{{end}} +
LinkJoinsUsedExpires
{{.Group}}
as {{.Role}}
{{.Uses}} of {{.Max}}{{.Expires}}{{if .Expired}}
spent or expired{{end}} + {{if .By}}
by {{.By}}{{end}}
+
+ + + + +
+
+{{else}} +

No invites outstanding.

+{{end}} + +{{if or .Groups .ServerAdmin}} +
New invite +
+ + +
+
+
+
+
+
+
+
+ +
+

Anyone holding the link can create an account, so send it the way you +would send a password and keep it short-lived. Revoking a link stops it at once.

+
+{{end}} +{{end}}`)) + +func (s *Server) renderGroups(w http.ResponseWriter, sess *webSession, errMsg, notice string) { + d := s.shell(sess, "Groups", "Groups") + s.st.Read(func(st *store.State) { + d["Groups"] = groupTree(st, sess.user) + d["Parents"] = administeredGroups(st, sess.user) + }) + d["Error"], d["Notice"] = errMsg, notice + render(w, groupsTmpl, d) +} + +func (s *Server) renderGroup(w http.ResponseWriter, sess *webSession, id, errMsg, notice string) { + d := s.shell(sess, "Group", "Groups") + var detail *webGroupDetail + s.st.Read(func(st *store.State) { detail = groupDetail(st, sess.user, id) }) + if detail == nil { + s.renderGroups(w, sess, "There is no such group, or it is not yours to see.", "") + return + } + d["G"], d["Error"], d["Notice"] = detail, errMsg, notice + render(w, groupTmpl, d) +} + +func (s *Server) renderUsers(w http.ResponseWriter, sess *webSession, errMsg, notice string) { + d := s.shell(sess, "People", "People") + s.st.Read(func(st *store.State) { d["Users"] = userRows(st, sess.user) }) + d["Error"], d["Notice"] = errMsg, notice + render(w, usersTmpl, d) +} + +func (s *Server) renderInvites(w http.ResponseWriter, sess *webSession, errMsg, notice string) { + d := s.shell(sess, "Invites", "Invites") + s.st.Read(func(st *store.State) { + d["Invites"] = s.inviteRows(st, sess.user) + d["Groups"] = administeredGroups(st, sess.user) + }) + d["Error"], d["Notice"] = errMsg, notice + render(w, invitesTmpl, d) +} diff --git a/internal/server/server.go b/internal/server/server.go index 52f5477..d3e6fd3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -180,16 +180,26 @@ func (s *Server) routes() { m.HandleFunc("POST /api/v1/auth/device/start", s.handleDeviceStart) m.HandleFunc("POST /api/v1/auth/device/poll", s.handleDevicePoll) - // The web interface: sign in, see your vaults, create one. + // The web interface: sign in, see your vaults, create one, and run the + // server. Every page goes through webPage and every form through + // webForm, which is where the session and the CSRF token are checked. m.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/web", http.StatusFound) }) m.HandleFunc("GET /web", s.handleWebHome) m.HandleFunc("POST /web/signin", s.handleWebSignIn) - m.HandleFunc("POST /web/signout", s.handleWebSignOut) - m.HandleFunc("GET /web/vaults", s.handleWebVaults) - m.HandleFunc("POST /web/vaults", s.handleWebCreateVault) - m.HandleFunc("POST /web/password", s.handleWebChangePassword) + m.HandleFunc("POST /web/signout", s.webForm(s.handleWebSignOut)) + m.HandleFunc("GET /web/vaults", s.webPage(s.handleWebVaults)) + m.HandleFunc("POST /web/vaults", s.webForm(s.handleWebCreateVault)) + m.HandleFunc("POST /web/password", s.webForm(s.handleWebChangePassword)) + m.HandleFunc("GET /web/groups", s.webPage(s.handleWebGroups)) + m.HandleFunc("POST /web/groups", s.webForm(s.handleWebCreateGroup)) + m.HandleFunc("GET /web/groups/{id}", s.webPage(s.handleWebGroup)) + m.HandleFunc("POST /web/groups/{id}", s.webForm(s.handleWebGroupAction)) + m.HandleFunc("GET /web/users", s.webPage(s.handleWebUsers)) + m.HandleFunc("POST /web/users", s.webForm(s.handleWebUserAction)) + m.HandleFunc("GET /web/invites", s.webPage(s.handleWebInvites)) + m.HandleFunc("POST /web/invites", s.webForm(s.handleWebInviteAction)) // Browser-facing pages: device approval, invite registration, OIDC. m.HandleFunc("GET /device", s.handleDevicePage) @@ -222,7 +232,9 @@ func (s *Server) routes() { m.HandleFunc("DELETE /api/v1/grants", s.authed(s.handleDeleteGrant)) m.HandleFunc("POST /api/v1/invites", s.authed(s.handleCreateInvite)) m.HandleFunc("GET /api/v1/invites", s.authed(s.handleListInvites)) + m.HandleFunc("DELETE /api/v1/invites/{token}", s.authed(s.handleRevokeInvite)) m.HandleFunc("GET /api/v1/users", s.authed(s.handleListUsers)) + m.HandleFunc("PATCH /api/v1/users/{id}", s.authed(s.handlePatchUser)) } // ---- request plumbing ---- diff --git a/internal/server/web.go b/internal/server/web.go index c0a3337..7fdcda5 100644 --- a/internal/server/web.go +++ b/internal/server/web.go @@ -11,9 +11,10 @@ import ( "cc.tiredsysadmin.keeporg/internal/store" ) -// The web interface exists for one job the command line cannot do well: -// creating a vault and setting its password in a browser. It is not a -// password manager and never shows a vault's contents; the server does not +// The web interface covers what a browser is better at than the command +// line: creating a vault and setting its password, and the administration of +// people — invites, accounts, groups and who reaches which vault. It is not +// a password manager and never shows a vault's contents; the server does not // open a vault except to merge one. const webCookie = "keeporg_web" @@ -44,6 +45,39 @@ func (s *Server) webUser(r *http.Request) *webSession { return out } +// webHandler is a handler that needs a signed-in browser. +type webHandler func(http.ResponseWriter, *http.Request, *webSession) + +// webPage serves a page to a signed-in browser, and the sign-in form to +// anyone else. +func (s *Server) webPage(fn webHandler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sess := s.webUser(r) + if sess == nil { + s.renderSignIn(w, "") + return + } + fn(w, r, sess) + } +} + +// webForm accepts a form from a signed-in browser. Every form on every page +// goes through here, so the CSRF check cannot be forgotten on one of them. +func (s *Server) webForm(fn webHandler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sess := s.webUser(r) + if sess == nil { + s.renderSignIn(w, "") + return + } + if err := r.ParseForm(); err != nil || !s.checkCSRF(r, sess) { + fail(w, http.StatusForbidden, "csrf", "stale form, reload the page") + return + } + fn(w, r, sess) + } +} + // checkCSRF verifies the form token against the session's. func (s *Server) checkCSRF(r *http.Request, sess *webSession) bool { return sess.csrf != "" && secret.Equal(sess.csrf, r.FormValue("csrf")) @@ -99,16 +133,7 @@ func (s *Server) handleWebSignIn(w http.ResponseWriter, r *http.Request) { } } -func (s *Server) handleWebSignOut(w http.ResponseWriter, r *http.Request) { - sess := s.webUser(r) - if sess == nil { - http.Redirect(w, r, "/web", http.StatusFound) - return - } - if err := r.ParseForm(); err != nil || !s.checkCSRF(r, sess) { - fail(w, http.StatusForbidden, "csrf", "stale form, reload the page") - return - } +func (s *Server) handleWebSignOut(w http.ResponseWriter, r *http.Request, _ *webSession) { if c, err := r.Cookie(webCookie); err == nil { _ = s.st.Update(func(st *store.State) error { delete(st.Sessions, c.Value) @@ -131,31 +156,17 @@ type webVault struct { HasKey bool } -// webGroup is one option in the owning-group selector. +// webGroup is one option in a group selector. type webGroup struct { ID string Name string } -func (s *Server) handleWebVaults(w http.ResponseWriter, r *http.Request) { - sess := s.webUser(r) - if sess == nil { - s.renderSignIn(w, "") - return - } +func (s *Server) handleWebVaults(w http.ResponseWriter, r *http.Request, sess *webSession) { s.renderVaults(w, sess, "", "") } -func (s *Server) handleWebCreateVault(w http.ResponseWriter, r *http.Request) { - sess := s.webUser(r) - if sess == nil { - s.renderSignIn(w, "") - return - } - if err := r.ParseForm(); err != nil || !s.checkCSRF(r, sess) { - fail(w, http.StatusForbidden, "csrf", "stale form, reload the page") - return - } +func (s *Server) handleWebCreateVault(w http.ResponseWriter, r *http.Request, sess *webSession) { password := r.FormValue("password") if password != r.FormValue("password2") { s.renderVaults(w, sess, "The two vault passwords do not match.", "") @@ -176,16 +187,7 @@ func (s *Server) handleWebCreateVault(w http.ResponseWriter, r *http.Request) { "with the password you just set.") } -func (s *Server) handleWebChangePassword(w http.ResponseWriter, r *http.Request) { - sess := s.webUser(r) - if sess == nil { - s.renderSignIn(w, "") - return - } - if err := r.ParseForm(); err != nil || !s.checkCSRF(r, sess) { - fail(w, http.StatusForbidden, "csrf", "stale form, reload the page") - return - } +func (s *Server) handleWebChangePassword(w http.ResponseWriter, r *http.Request, sess *webSession) { next := r.FormValue("new_password") if next != r.FormValue("new_password2") { s.renderVaults(w, sess, "The two new passwords do not match.", "") @@ -218,7 +220,7 @@ func (s *Server) handleWebChangePassword(w http.ResponseWriter, r *http.Request) // humanError keeps internal wording out of the browser. func humanError(err error) string { if errors.Is(err, errForbidden) { - return "You do not administer that group." + return "You do not administer that." } return err.Error() } diff --git a/internal/server/webadmin.go b/internal/server/webadmin.go new file mode 100644 index 0000000..b07a879 --- /dev/null +++ b/internal/server/webadmin.go @@ -0,0 +1,485 @@ +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) +} diff --git a/internal/server/webadmin_test.go b/internal/server/webadmin_test.go new file mode 100644 index 0000000..d37c82f --- /dev/null +++ b/internal/server/webadmin_test.go @@ -0,0 +1,368 @@ +package server + +import ( + "net/http" + "net/url" + "regexp" + "strings" + "testing" + + "cc.tiredsysadmin.keeporg/internal/store" +) + +// signInAs takes the browser through the sign-in form as any account. +func (b *browser) signInAs(user, password string) string { + b.t.Helper() + status, page := b.post("/web/signin", url.Values{ + "username": {user}, "password": {password}}) + if status != http.StatusOK { + b.t.Fatalf("sign in as %s: %d\n%s", user, status, page) + } + return b.csrf(page) +} + +var inviteLink = regexp.MustCompile(`/invite/([0-9a-f]{64})`) + +// invite creates an invite from the web form and returns its token. +func (b *browser) invite(csrf, group, role string) string { + b.t.Helper() + status, page := b.post("/web/invites", url.Values{ + "csrf": {csrf}, "action": {"create"}, "group": {group}, "role": {role}, + "ttl": {"24"}, "uses": {"1"}}) + if status != http.StatusOK { + b.t.Fatalf("create invite: %d\n%s", status, page) + } + m := inviteLink.FindStringSubmatch(page) + if m == nil { + b.t.Fatalf("the new invite link is not on the page:\n%s", page) + } + return m[1] +} + +// join redeems an invite, which is how every account after the first is made. +func (h *harness) join(token, username, password string) { + h.t.Helper() + resp, err := h.http.Client().PostForm(h.http.URL+"/invite/"+token, url.Values{ + "username": {username}, "password": {password}, "password2": {password}}) + if err != nil { + h.t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + h.t.Fatalf("redeem invite: %d", resp.StatusCode) + } +} + +func TestWebAdminNavigationFollowsWhatYouAdminister(t *testing.T) { + h := newHarness(t) + admin := newBrowser(t, h) + csrf := admin.signIn() + + _, page := admin.get("/web/vaults") + for _, want := range []string{`href="/web/groups"`, `href="/web/users"`, `href="/web/invites"`} { + if !strings.Contains(page, want) { + t.Fatalf("an administrator was not offered %s:\n%s", want, page) + } + } + + h.join(admin.invite(csrf, "everyone", "member"), "plain", "plain-password") + member := newBrowser(t, h) + _, page = member.post("/web/signin", url.Values{ + "username": {"plain"}, "password": {"plain-password"}}) + for _, gone := range []string{`href="/web/groups"`, `href="/web/users"`, `href="/web/invites"`} { + if strings.Contains(page, gone) { + t.Fatalf("a plain member was offered %s:\n%s", gone, page) + } + } + // The pages themselves still only ever show what the viewer may see. + _, page = member.get("/web/users") + if strings.Contains(page, "admin<") || strings.Contains(page, "Delete") { + t.Fatalf("a plain member saw other accounts or their controls:\n%s", page) + } +} + +func TestWebRunsAGroupFromMembersToGrants(t *testing.T) { + h := newHarness(t) + b := newBrowser(t, h) + csrf := b.signIn() + h.join(b.invite(csrf, "everyone", "member"), "dana", "dana-password-1") + + status, page := b.post("/web/groups", url.Values{ + "csrf": {csrf}, "name": {"Engineering"}, "parent": {"everyone"}}) + if status != http.StatusOK || !strings.Contains(page, "Created the group Engineering") { + t.Fatalf("create group = %d\n%s", status, page) + } + var group string + h.srv.Store().Read(func(st *store.State) { + for id, g := range st.Groups { + if g.Name == "Engineering" { + group = id + } + } + }) + if group == "" { + t.Fatal("the group was not stored") + } + if !strings.Contains(page, "/web/groups/"+group) { + t.Fatal("the new group is not listed") + } + + // A member, then promoted, then demoted. + _, page = b.post("/web/groups/"+group, url.Values{ + "csrf": {csrf}, "action": {"member"}, "user": {"dana"}, "role": {"member"}}) + if !strings.Contains(page, "dana") || !strings.Contains(page, "Make admin") { + t.Fatalf("dana was not added:\n%s", page) + } + _, page = b.post("/web/groups/"+group, url.Values{ + "csrf": {csrf}, "action": {"member"}, "user": {"dana"}, "role": {"admin"}}) + if !strings.Contains(page, "Make member") { + t.Fatalf("dana was not promoted:\n%s", page) + } + var role store.Role + h.srv.Store().Read(func(st *store.State) { + for _, m := range st.Memberships { + if m.GroupID == group { + role = m.Role + } + } + }) + if role != store.RoleAdmin { + t.Fatalf("stored role = %q", role) + } + + // A vault the group may then reach. + if _, p := b.post("/web/vaults", url.Values{ + "csrf": {csrf}, "name": {"Shared"}, "group": {"everyone"}, + "strategy": {"server-wins"}, "password": {vaultPassword}, "password2": {vaultPassword}, + }); !strings.Contains(p, "Created Shared") { + t.Fatalf("vault not created:\n%s", p) + } + var dbID string + h.srv.Store().Read(func(st *store.State) { + for id, d := range st.Databases { + if d.Name == "Shared" { + dbID = id + } + } + }) + _, page = b.post("/web/groups/"+group, url.Values{ + "csrf": {csrf}, "action": {"grant"}, "database": {dbID}, "perm": {"write"}}) + if !strings.Contains(page, "Shared.kdbx") || !strings.Contains(page, "granted here") { + t.Fatalf("the grant is not shown:\n%s", page) + } + var perm store.Perm + h.srv.Store().Read(func(st *store.State) { + if u := st.UserByName("dana"); u != nil { + perm = st.DatabasePerm(u.ID, dbID) + } + }) + if perm != store.PermWrite { + t.Fatalf("dana's permission on the vault = %q", perm) + } + + // And back out again, in the order the group's page allows. + _, page = b.post("/web/groups/"+group, url.Values{ + "csrf": {csrf}, "action": {"grant-remove"}, "database": {dbID}}) + if !strings.Contains(page, "no longer has access") { + t.Fatalf("the grant was not revoked:\n%s", page) + } + _, page = b.post("/web/groups/"+group, url.Values{ + "csrf": {csrf}, "action": {"member-remove"}, "user": {"dana"}}) + if !strings.Contains(page, "was removed from this group") { + t.Fatalf("dana was not removed:\n%s", page) + } + status, page = b.post("/web/groups/"+group, url.Values{ + "csrf": {csrf}, "action": {"delete"}}) + if status != http.StatusOK || strings.Contains(page, "/web/groups/"+group) { + t.Fatalf("delete = %d, the group is still listed:\n%s", status, page) + } +} + +// A grant on a group above is why a member has access, and the group's page +// has to say so rather than showing an empty list. +func TestWebGroupShowsInheritedAccess(t *testing.T) { + h := newHarness(t) + b := newBrowser(t, h) + csrf := b.signIn() + if _, p := b.post("/web/vaults", url.Values{ + "csrf": {csrf}, "name": {"All Hands"}, "group": {"everyone"}, + "strategy": {"server-wins"}, "password": {vaultPassword}, "password2": {vaultPassword}, + }); !strings.Contains(p, "Created All Hands") { + t.Fatalf("vault not created:\n%s", p) + } + var dbID string + h.srv.Store().Read(func(st *store.State) { + for id, d := range st.Databases { + if d.Name == "All Hands" { + dbID = id + } + } + }) + if _, p := b.post("/web/groups/everyone", url.Values{ + "csrf": {csrf}, "action": {"grant"}, "database": {dbID}, "perm": {"read"}, + }); !strings.Contains(p, "read access to All Hands") { + t.Fatalf("the grant was not made:\n%s", p) + } + _, page := b.post("/web/groups", url.Values{ + "csrf": {csrf}, "name": {"Below"}, "parent": {"everyone"}}) + m := regexp.MustCompile(`/web/groups/([0-9a-f]{16})`).FindStringSubmatch(page) + if m == nil { + t.Fatalf("no group link:\n%s", page) + } + _, page = b.get("/web/groups/" + m[1]) + if !strings.Contains(page, "granted to everyone") { + t.Fatalf("the inherited grant is not explained:\n%s", page) + } +} + +func TestWebInviteCanBeRevoked(t *testing.T) { + h := newHarness(t) + b := newBrowser(t, h) + csrf := b.signIn() + token := b.invite(csrf, "everyone", "admin") + + if status, page := b.get("/invite/" + token); status != http.StatusOK || + !strings.Contains(page, "Create your account") { + t.Fatalf("the invite page = %d\n%s", status, page) + } + _, page := b.post("/web/invites", url.Values{ + "csrf": {csrf}, "action": {"revoke"}, "token": {token}}) + if !strings.Contains(page, "no longer works") { + t.Fatalf("revoke:\n%s", page) + } + if _, page := b.get("/invite/" + token); !strings.Contains(page, "Invite not valid") { + t.Fatalf("a revoked invite still opens:\n%s", page) + } +} + +// The role on the invite is the role the new account gets, which is the only +// way a group administrator can hand their group to somebody else. +func TestWebInviteCarriesItsRole(t *testing.T) { + h := newHarness(t) + b := newBrowser(t, h) + csrf := b.signIn() + h.join(b.invite(csrf, "everyone", "admin"), "lead", "lead-password-1") + var admin bool + h.srv.Store().Read(func(st *store.State) { + if u := st.UserByName("lead"); u != nil { + admin = st.IsGroupAdmin(u.ID, "everyone") + } + }) + if !admin { + t.Fatal("the invited account did not become an administrator of the group") + } +} + +func TestWebAccountActionsAreServerAdminOnly(t *testing.T) { + h := newHarness(t) + b := newBrowser(t, h) + csrf := b.signIn() + // An administrator of a group, which is not the same as administering + // the people in it. + h.join(b.invite(csrf, "everyone", "admin"), "lead", "lead-password-1") + h.join(b.invite(csrf, "everyone", "member"), "victim", "victim-password-1") + + lead := newBrowser(t, h) + leadCSRF := lead.signInAs("lead", "lead-password-1") + var victim string + h.srv.Store().Read(func(st *store.State) { victim = st.UserByName("victim").ID }) + _, page := lead.post("/web/users", url.Values{ + "csrf": {leadCSRF}, "user": {victim}, "action": {"disable"}}) + if !strings.Contains(page, "do not administer") { + t.Fatalf("a group administrator was allowed to disable an account:\n%s", page) + } + var disabled bool + h.srv.Store().Read(func(st *store.State) { disabled = st.Users[victim].Disabled }) + if disabled { + t.Fatal("the account was disabled anyway") + } +} + +func TestWebDisableSignsTheAccountOutEverywhere(t *testing.T) { + h := newHarness(t) + b := newBrowser(t, h) + csrf := b.signIn() + h.join(b.invite(csrf, "everyone", "member"), "gone", "gone-password-1") + token, _, _ := h.login("gone", "gone-password-1", "") + if status, _ := h.call("GET", "/api/v1/databases", token, nil); status != http.StatusOK { + t.Fatalf("the new account cannot list databases: %d", status) + } + + var id string + h.srv.Store().Read(func(st *store.State) { id = st.UserByName("gone").ID }) + _, page := b.post("/web/users", url.Values{ + "csrf": {csrf}, "user": {id}, "action": {"disable"}}) + if !strings.Contains(page, "signed out everywhere") { + t.Fatalf("disable:\n%s", page) + } + if status, _ := h.call("GET", "/api/v1/databases", token, nil); status != http.StatusUnauthorized { + t.Fatalf("the disabled account's token still works: %d", status) + } + if _, body := h.call("POST", "/api/v1/auth/login", "", + map[string]string{"username": "gone", "password": "gone-password-1"}); body["token"] != nil { + t.Fatal("a disabled account signed in again") + } + + // Enabling restores the account, and deleting it takes the memberships. + if _, page := b.post("/web/users", url.Values{ + "csrf": {csrf}, "user": {id}, "action": {"enable"}}); !strings.Contains(page, "can sign in again") { + t.Fatalf("enable:\n%s", page) + } + if _, page := b.post("/web/users", url.Values{ + "csrf": {csrf}, "user": {id}, "action": {"delete"}}); !strings.Contains(page, "was deleted") { + t.Fatalf("delete:\n%s", page) + } + h.srv.Store().Read(func(st *store.State) { + if st.Users[id] != nil { + t.Fatal("the account is still there") + } + for _, m := range st.Memberships { + if m.UserID == id { + t.Fatal("a membership outlived the account") + } + } + }) +} + +// Locking the last administrator out of a server is only repairable from a +// shell on the host, so the three ways to do it are refused. +func TestWebRefusesToLockYouOutOfYourOwnAccount(t *testing.T) { + h := newHarness(t) + b := newBrowser(t, h) + csrf := b.signIn() + for _, act := range []string{"disable", "demote", "delete"} { + _, page := b.post("/web/users", url.Values{ + "csrf": {csrf}, "user": {"admin"}, "action": {act}}) + if !strings.Contains(page, "lock you out") { + t.Fatalf("%s on your own account was not refused:\n%s", act, page) + } + } + h.srv.Store().Read(func(st *store.State) { + u := st.Users["admin"] + if u == nil || u.Disabled || !u.ServerAdmin { + t.Fatalf("the administrator's own account was changed: %+v", u) + } + }) +} + +func TestWebAdminFormsNeedTheirToken(t *testing.T) { + h := newHarness(t) + b := newBrowser(t, h) + b.signIn() + for _, path := range []string{"/web/groups", "/web/users", "/web/invites"} { + if status, _ := b.post(path, url.Values{"action": {"create"}}); status != http.StatusForbidden { + t.Fatalf("POST %s without a CSRF token returned %d", path, status) + } + } +} + +// A signed-out browser is offered the sign-in form, not an error. +func TestWebAdminPagesNeedASession(t *testing.T) { + b := newBrowser(t, newHarness(t)) + for _, path := range []string{"/web/groups", "/web/users", "/web/invites"} { + status, page := b.get(path) + if status != http.StatusOK || !strings.Contains(page, "Sign in") { + t.Fatalf("GET %s while signed out = %d\n%s", path, status, page) + } + } +} diff --git a/packaging/deb/server/changelog b/packaging/deb/server/changelog index 77e9021..a2a6f6f 100644 --- a/packaging/deb/server/changelog +++ b/packaging/deb/server/changelog @@ -1,3 +1,10 @@ +keeporgd (0.6.0) unstable; urgency=medium + + * Add the administration pages: groups and their members, accounts, + invites and vault grants from a browser + + -- tiredsysadmin Fri, 30 Oct 2026 12:28:50 +0100 + keeporgd (0.5.0) unstable; urgency=medium * Single sign-on now works from the web interface. The link on the diff --git a/versions.mk b/versions.mk index 9b188e4..1d76225 100644 --- a/versions.mk +++ b/versions.mk @@ -1,6 +1,6 @@ # Single source of truth for all platform versions (semver). # Bump with: make bump PLATFORM= PART= -VERSION_SERVER := 0.5.0 +VERSION_SERVER := 0.6.0 VERSION_LINUX := 0.3.0 VERSION_MACOS := 0.3.0 VERSION_WINDOWS := 0.3.0