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.
+39 -19
View File
@@ -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"}}<!doctype html>
<html lang="en"><head><meta charset="utf-8">
@@ -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}
</style></head><body{{if .Wide}} class="wide"{{end}}><main>{{template "body" .}}</main></body></html>{{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}
</style></head><body{{if .Wide}} class="wide"{{end}}><main>{{if .Nav}}<nav>{{range .Nav}}{{if .Current}}<span class="here">{{.Label}}</span>{{else}}<a href="{{.Href}}">{{.Label}}</a>{{end}}{{end}}<form class="out" method="post" action="/web/signout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button type="submit">Sign out {{.User}}</button></form></nav>{{end}}{{template "body" .}}</main></body></html>{{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"}}
<header>
<div><h1>Your vaults</h1><p class="sub">{{.User}}</p></div>
<form method="post" action="/web/signout">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button type="submit">Sign out</button>
</form>
</header>
<h1>Your vaults</h1>
<p class="sub">The files your KeepOrg client keeps in sync.</p>
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
{{if .Notice}}<p class="ok">{{.Notice}}</p>{{end}}
{{if .Vaults}}
<table>
<div class="scroll"><table>
<thead><tr><th>Vault</th><th>Access</th><th>On conflict</th><th>Version</th><th>Last saved</th></tr></thead>
<tbody>
{{range .Vaults}}<tr>
@@ -121,7 +142,7 @@ var vaultsTmpl = template.Must(template.Must(pages.Clone()).Parse(`
<td>{{.Version}}</td>
<td>{{.Updated}}{{if .By}}<br><small>by {{.By}}</small>{{end}}</td>
</tr>{{end}}
</tbody></table>
</tbody></table></div>
{{else}}
<p class="empty">No vaults yet. Create one below, then open it from the KeepOrg client
on your computer or phone.</p>
@@ -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) {
+351
View File
@@ -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"}}
<h1>Groups</h1>
<p class="sub">Nested groups decide who reaches which vault.</p>
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
{{if .Notice}}<p class="ok">{{.Notice}}</p>{{end}}
{{if .Groups}}
<div class="scroll"><table>
<thead><tr><th>Group</th><th>People</th><th>Vaults</th><th></th></tr></thead>
<tbody>
{{range .Groups}}<tr>
<td class="name" style="padding-left:{{.Pad}}px"><a href="/web/groups/{{.ID}}">{{.Name}}</a></td>
<td>{{.Members}}</td>
<td>{{.Owns}}</td>
<td>{{if .Admin}}<span class="tag">you administer</span>{{end}}</td>
</tr>{{end}}
</tbody></table></div>
{{else}}
<p class="empty">There are no groups you can see.</p>
{{end}}
{{if or .Parents .ServerAdmin}}
<fieldset><legend>New group</legend>
<form class="inline" method="post" action="/web/groups">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<div><label for="name">Name</label>
<input id="name" name="name" required placeholder="Engineering"></div>
<div><label for="parent">Inside</label>
<select id="parent" name="parent">
{{if .ServerAdmin}}<option value="">(top level)</option>{{end}}
{{range .Parents}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select></div>
<button type="submit">Create group</button>
</form>
<p class="note">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.</p>
</fieldset>
{{end}}
{{end}}`))
var groupTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{define "body"}}
{{with .G}}
<h1>{{.Name}}</h1>
<p class="sub">{{if .Parent}}Inside {{.Parent}}. {{end}}<a href="/web/groups">All groups</a></p>
{{end}}
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
{{if .Notice}}<p class="ok">{{.Notice}}</p>{{end}}
{{with .G}}
<h2>People</h2>
{{if .Members}}
<div class="scroll"><table>
<thead><tr><th>Who</th><th>Role</th><th></th></tr></thead>
<tbody>
{{range .Members}}<tr{{if .Disabled}} class="gone"{{end}}>
<td class="name">{{.Username}}{{if .Disabled}} <span class="tag">disabled</span>{{end}}</td>
<td>{{.Role}}</td>
<td>{{if $.G.Admin}}
<form method="post" action="/web/groups/{{$.G.ID}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="member">
<input type="hidden" name="user" value="{{.ID}}">
<input type="hidden" name="role" value="{{if eq .Role "admin"}}member{{else}}admin{{end}}">
<button type="submit">Make {{if eq .Role "admin"}}member{{else}}admin{{end}}</button>
</form>
<form method="post" action="/web/groups/{{$.G.ID}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="member-remove">
<input type="hidden" name="user" value="{{.ID}}">
<button class="danger" type="submit">Remove</button>
</form>
{{end}}</td>
</tr>{{end}}
</tbody></table></div>
{{else}}<p class="empty">Nobody is in this group yet.</p>{{end}}
{{if .Admin}}
<fieldset><legend>Add somebody</legend>
<form class="inline" method="post" action="/web/groups/{{.ID}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="member">
<div><label for="user">Username</label>
<input id="user" name="user" list="people" required autocomplete="off">
<datalist id="people">{{range .People}}<option value="{{.Name}}">{{end}}</datalist></div>
<div><label for="role">Role</label>
<select id="role" name="role">
<option value="member">Member</option>
<option value="admin">Administrator of this group</option>
</select></div>
<button type="submit">Add</button>
</form>
<p class="note">Only accounts that already exist can be added. To bring in somebody
new, send them an <a href="/web/invites">invite link</a> for this group.</p>
</fieldset>
{{end}}
<h2>Vaults this group reaches</h2>
{{if .Access}}
<div class="scroll"><table>
<thead><tr><th>Vault</th><th>Access</th><th>Where from</th><th></th></tr></thead>
<tbody>
{{range .Access}}<tr>
<td class="name">{{.Name}}.kdbx</td>
<td>{{.Perm}}</td>
<td>{{if .Owned}}owns it{{else if .Via}}granted to {{.Via}}{{else}}granted here{{end}}</td>
<td>{{if and $.G.Admin (not .Owned) (not .Via)}}
<form method="post" action="/web/groups/{{$.G.ID}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="grant-remove">
<input type="hidden" name="database" value="{{.DatabaseID}}">
<button class="danger" type="submit">Revoke</button>
</form>
{{end}}</td>
</tr>{{end}}
</tbody></table></div>
{{else}}<p class="empty">This group reaches no vaults.</p>{{end}}
{{if and .Admin .Vaults}}
<fieldset><legend>Grant access</legend>
<form class="inline" method="post" action="/web/groups/{{.ID}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="grant">
<div><label for="database">Vault</label>
<select id="database" name="database" required>
{{range .Vaults}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select></div>
<div><label for="perm">May</label>
<select id="perm" name="perm">
<option value="read">Read it</option>
<option value="write">Read and save it</option>
<option value="admin">Administer it</option>
</select></div>
<button type="submit">Grant</button>
</form>
</fieldset>
{{end}}
{{if .Children}}
<h2>Groups inside this one</h2>
<div class="scroll"><table><tbody>
{{range .Children}}<tr>
<td class="name"><a href="/web/groups/{{.ID}}">{{.Name}}</a></td>
<td>{{.Members}} people</td><td>{{.Owns}} vaults</td>
</tr>{{end}}
</tbody></table></div>
{{end}}
{{if .Admin}}
<details>
<summary>Delete this group</summary>
<p class="sub">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.</p>
<form method="post" action="/web/groups/{{.ID}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="delete">
<button type="submit">Delete {{.Name}}</button>
</form>
</details>
{{end}}
{{end}}
{{end}}`))
var usersTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{define "body"}}
<h1>People</h1>
<p class="sub">{{if .ServerAdmin}}Every account on this server.{{else}}The people in the
groups you administer.{{end}}</p>
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
{{if .Notice}}<p class="ok">{{.Notice}}</p>{{end}}
<div class="scroll"><table>
<thead><tr><th>Who</th><th>Signs in with</th><th>Groups</th>{{if .ServerAdmin}}<th></th>{{end}}</tr></thead>
<tbody>
{{range .Users}}<tr{{if .Disabled}} class="gone"{{end}}>
<td class="name">{{.Username}}
{{if .ServerAdmin}}<span class="tag">server admin</span>{{end}}
{{if .Disabled}}<span class="tag">disabled</span>{{end}}
{{if .Email}}<br><small>{{.Email}}</small>{{end}}</td>
<td>{{.SignIn}}{{if .TOTP}}<br><small>one-time code</small>{{end}}</td>
<td>{{if .Groups}}{{.Groups}}{{else}}<small>none</small>{{end}}</td>
{{if $.ServerAdmin}}<td>
{{if .Disabled}}
<form method="post" action="/web/users">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="user" value="{{.ID}}">
<input type="hidden" name="action" value="enable">
<button type="submit">Enable</button>
</form>
{{else if not .Self}}
<form method="post" action="/web/users">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="user" value="{{.ID}}">
<input type="hidden" name="action" value="disable">
<button type="submit">Disable</button>
</form>
{{end}}
{{if .TOTP}}
<form method="post" action="/web/users">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="user" value="{{.ID}}">
<input type="hidden" name="action" value="clear-totp">
<button type="submit">Clear 2FA</button>
</form>
{{end}}
{{if not .Self}}
<form method="post" action="/web/users">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="user" value="{{.ID}}">
<input type="hidden" name="action" value="{{if .ServerAdmin}}demote{{else}}promote{{end}}">
<button type="submit">{{if .ServerAdmin}}Drop server admin{{else}}Make server admin{{end}}</button>
</form>
<form method="post" action="/web/users">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="user" value="{{.ID}}">
<input type="hidden" name="action" value="delete">
<button class="danger" type="submit">Delete</button>
</form>
{{end}}
</td>{{end}}
</tr>{{end}}
</tbody></table></div>
{{if .ServerAdmin}}
<p class="note">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.</p>
{{else}}
<p class="note">Only a server administrator can change an account. You decide who
is in your groups, from each <a href="/web/groups">group's page</a>.</p>
{{end}}
{{end}}`))
var invitesTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{define "body"}}
<h1>Invites</h1>
<p class="sub">An invite link lets somebody create their own account and lands them
in one group.</p>
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
{{if .Notice}}<p class="ok">{{.Notice}}</p>{{end}}
{{if .Invites}}
<div class="scroll"><table>
<thead><tr><th>Link</th><th>Joins</th><th>Used</th><th>Expires</th><th></th></tr></thead>
<tbody>
{{range .Invites}}<tr{{if .Expired}} class="gone"{{end}}>
<td><input readonly value="{{.URL}}" aria-label="invite link"></td>
<td>{{.Group}}<br><small>as {{.Role}}</small></td>
<td>{{.Uses}} of {{.Max}}</td>
<td>{{.Expires}}{{if .Expired}}<br><small>spent or expired</small>{{end}}
{{if .By}}<br><small>by {{.By}}</small>{{end}}</td>
<td>
<form method="post" action="/web/invites">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="action" value="revoke">
<input type="hidden" name="token" value="{{.Token}}">
<button class="danger" type="submit">Revoke</button>
</form>
</td>
</tr>{{end}}
</tbody></table></div>
{{else}}
<p class="empty">No invites outstanding.</p>
{{end}}
{{if or .Groups .ServerAdmin}}
<fieldset><legend>New invite</legend>
<form class="inline" method="post" action="/web/invites">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="action" value="create">
<div><label for="group">Joins</label>
<select id="group" name="group">
{{range .Groups}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
{{if .ServerAdmin}}<option value="">(no group)</option>{{end}}
</select></div>
<div><label for="role">As</label>
<select id="role" name="role">
<option value="member">Member</option>
<option value="admin">Administrator of that group</option>
</select></div>
<div><label for="ttl">Valid for</label>
<select id="ttl" name="ttl">
<option value="1">1 hour</option>
<option value="24">1 day</option>
<option value="168" selected>7 days</option>
<option value="720">30 days</option>
</select></div>
<div><label for="uses">Uses</label>
<select id="uses" name="uses">
<option value="1">1</option>
<option value="5">5</option>
<option value="25">25</option>
<option value="-1">unlimited</option>
</select></div>
<button type="submit">Create invite</button>
</form>
<p class="note">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.</p>
</fieldset>
{{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)
}
+17 -5
View File
@@ -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 ----
+43 -41
View File
@@ -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()
}
+485
View File
@@ -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)
}
+368
View File
@@ -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)
}
}
}