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 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-10-30 12:32:08 +01:00
co-authored by Claude Opus 5
parent 3a499532f3
commit 81094c0228
11 changed files with 1783 additions and 274 deletions
+413 -196
View File
@@ -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.