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:
co-authored by
Claude Opus 5
parent
3a499532f3
commit
81094c0228
+413
-196
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user