From 81094c0228c4171c78d10eb7b79c89d0a3ffd7fa Mon Sep 17 00:00:00 2001 From: Reactorcoremeltdown Date: Fri, 30 Oct 2026 12:32:08 +0100 Subject: [PATCH] Add the administration pages to the web interface Running the server meant running the API by hand: invites, accounts, groups and grants existed only as endpoints and CLI commands, so the browser could create a vault and nothing else. /web/groups walks the group tree and opens onto one group's members, roles and vault access, including the grants it inherits from above and where they come from. /web/users lists the accounts you may see, and lets a server administrator disable, enable, clear a lost second factor, promote, demote or delete one. /web/invites shows the outstanding links with their use counts, issues new ones and revokes them. Every administrative change is now a function of the state and the caller in admin.go, which both the JSON handlers and the forms call. The alternative was a second copy of the permission rules behind the pages, which would have drifted on the first change. The same move gave the API the two verbs it was missing: DELETE /api/v1/invites/{token} and PATCH /api/v1/users/{id}. Account changes are server-administrator only. A group administrator decides who reaches which vault; disabling an account or clearing somebody's second factor reaches past the group they share. Disable and delete revoke that account's sessions, and the three changes that would lock the caller out are refused on their own account. Pages go through webPage and forms through webForm, so the session and CSRF checks cannot be forgotten on a new form. No page carries any JavaScript. Server 0.6.0. Co-Authored-By: Claude Opus 5 --- docs/HANDOVER.md | 62 +++- docs/PROTOCOL.md | 9 + internal/server/admin.go | 609 +++++++++++++++++++++---------- internal/server/pages.go | 58 ++- internal/server/pagesadmin.go | 351 ++++++++++++++++++ internal/server/server.go | 22 +- internal/server/web.go | 84 ++--- internal/server/webadmin.go | 485 ++++++++++++++++++++++++ internal/server/webadmin_test.go | 368 +++++++++++++++++++ packaging/deb/server/changelog | 7 + versions.mk | 2 +- 11 files changed, 1783 insertions(+), 274 deletions(-) create mode 100644 internal/server/pagesadmin.go create mode 100644 internal/server/webadmin.go create mode 100644 internal/server/webadmin_test.go 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