ci/woodpecker/push/build Pipeline was successful
The web interface could create a vault and never remove one, so the only
way to undo a mistake was the CLI. There is now a Delete link on every
vault you administer, leading to a page that says what is about to go —
owner, versions stored, grants pointing at it — and asks for your
account password before it goes.
The gate is re-authentication, not a confirmation checkbox. A session
token is enough to read and write a vault; it should not be enough to
erase every version of one, because a session is what an unlocked
laptop leaves lying around. The one-time code is required too when the
account has one enrolled.
It lives in confirmDeletion, so the rule holds wherever deletion is
reachable: DELETE /api/v1/databases/{id} now takes {password, totp?}
and answers 401 without it, and keeporg db -delete prompts. That is a
breaking change to the endpoint, which is why the server goes to 0.7.0
and the desktop clients to 0.4.0.
An account that signs in only through an identity provider has no
password to type. Rather than lock those deployments out of deleting
anything, they confirm by typing the vault's exact name — the weaker
gate, and deliberately the fallback.
Deleting takes the record, its grants, every stored version and the key
a merge vault left with the server, and tells the watchers over SSE.
Copies already in people's mirror directories stay where they are: the
server stops tracking a vault, it does not reach into anybody's home
directory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
820 lines
23 KiB
Go
820 lines
23 KiB
Go
package server
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"cc.tiredsysadmin.keeporg/internal/secret"
|
|
"cc.tiredsysadmin.keeporg/internal/store"
|
|
)
|
|
|
|
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.
|
|
|
|
// denyAuth answers a request that failed to re-prove its credentials. The
|
|
// cases stay distinct so a client knows to prompt for the factor it is
|
|
// missing rather than reporting a flat refusal.
|
|
func denyAuth(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case errors.Is(err, errTOTPRequired):
|
|
fail(w, http.StatusUnauthorized, "totp_required", "second factor required")
|
|
case errors.Is(err, errTOTPInvalid):
|
|
fail(w, http.StatusUnauthorized, "totp_invalid", "second factor rejected")
|
|
case errors.Is(err, errBadCredentials):
|
|
fail(w, http.StatusUnauthorized, "bad_credentials",
|
|
"your own account password is required for this")
|
|
default:
|
|
deny(w, err)
|
|
}
|
|
}
|
|
|
|
// deny maps a domain error onto a response.
|
|
func deny(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case err == nil:
|
|
return
|
|
case errors.Is(err, errForbidden):
|
|
fail(w, http.StatusForbidden, "forbidden", "you do not administer that object")
|
|
default:
|
|
fail(w, http.StatusBadRequest, "bad_request", 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) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
OwnerGroupID string `json:"owner_group_id"`
|
|
MergeStrategy store.Strategy `json:"merge_strategy"`
|
|
}
|
|
if !readJSON(w, r, &req) {
|
|
return
|
|
}
|
|
var out dbView
|
|
err := s.st.Update(func(st *store.State) error {
|
|
if strings.TrimSpace(req.Name) == "" {
|
|
return errors.New("name is required")
|
|
}
|
|
if st.Groups[req.OwnerGroupID] == nil {
|
|
return errors.New("unknown owner group")
|
|
}
|
|
if !st.IsGroupAdmin(u.ID, req.OwnerGroupID) {
|
|
return errForbidden
|
|
}
|
|
strategy := req.MergeStrategy
|
|
if strategy == "" {
|
|
strategy = store.StrategyServerWins
|
|
}
|
|
if !strategy.Valid() {
|
|
return errors.New("unknown merge strategy")
|
|
}
|
|
db := &store.Database{ID: secret.ID(), Name: strings.TrimSpace(req.Name),
|
|
OwnerGroupID: req.OwnerGroupID, MergeStrategy: strategy, UpdatedAt: time.Now()}
|
|
st.Databases[db.ID] = db
|
|
out = toView(db, store.PermAdmin)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
deny(w, err)
|
|
return
|
|
}
|
|
s.notifyList(out.ID)
|
|
writeJSON(w, http.StatusCreated, out)
|
|
}
|
|
|
|
func (s *Server) handlePatchDatabase(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
id := r.PathValue("id")
|
|
var req struct {
|
|
Name *string `json:"name"`
|
|
MergeStrategy *store.Strategy `json:"merge_strategy"`
|
|
}
|
|
if !readJSON(w, r, &req) {
|
|
return
|
|
}
|
|
var out dbView
|
|
err := s.st.Update(func(st *store.State) error {
|
|
db := st.Databases[id]
|
|
if db == nil {
|
|
return errors.New("no such database")
|
|
}
|
|
if st.DatabasePerm(u.ID, id) != store.PermAdmin {
|
|
return errForbidden
|
|
}
|
|
if req.Name != nil {
|
|
if strings.TrimSpace(*req.Name) == "" {
|
|
return errors.New("name may not be empty")
|
|
}
|
|
db.Name = strings.TrimSpace(*req.Name)
|
|
}
|
|
if req.MergeStrategy != nil {
|
|
if !req.MergeStrategy.Valid() {
|
|
return errors.New("unknown merge strategy")
|
|
}
|
|
if req.MergeStrategy.NeedsKey() && db.WrappedKey == "" {
|
|
return errors.New("merging needs the vault password: store it with " +
|
|
"`keeporg db -id <id> -store-password` first")
|
|
}
|
|
db.MergeStrategy = *req.MergeStrategy
|
|
}
|
|
out = toView(db, store.PermAdmin)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
deny(w, err)
|
|
return
|
|
}
|
|
s.notifyList(id)
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// errConfirmName is the answer when an account with no password of its own
|
|
// did not type the name of the thing it is about to destroy.
|
|
var errConfirmName = errors.New("type the name exactly to confirm")
|
|
|
|
// confirmDeletion re-proves who the caller is before something irreversible.
|
|
// Holding a session is enough to read and write a vault; it is not enough to
|
|
// destroy every version of one, because a session is what an unlocked laptop
|
|
// leaves lying around.
|
|
//
|
|
// An account that signs in only through an identity provider has no password
|
|
// to type. Refusing those accounts would mean a deployment where nobody can
|
|
// delete anything, so they confirm by typing the exact name instead. That is
|
|
// a weaker gate and it is deliberately the fallback, not the rule.
|
|
func confirmDeletion(st *store.State, u *store.User, name, password, totpCode, typed string) error {
|
|
target := st.Users[u.ID]
|
|
if target == nil {
|
|
return errBadCredentials
|
|
}
|
|
if target.PassHash == "" {
|
|
if strings.TrimSpace(typed) != name {
|
|
return errConfirmName
|
|
}
|
|
return nil
|
|
}
|
|
_, err := checkPassword(st, target.Username, password, totpCode)
|
|
return err
|
|
}
|
|
|
|
// deleteDatabase removes a database, its grants and every stored version of
|
|
// the file. Clients keep whatever they already have on disk: the server can
|
|
// stop tracking a vault, but it has no business reaching into somebody's
|
|
// home directory.
|
|
func (s *Server) deleteDatabase(u *store.User, id, password, totpCode, typed string) (string, error) {
|
|
var (
|
|
name string
|
|
watchers []string
|
|
)
|
|
err := s.st.Update(func(st *store.State) error {
|
|
db := st.Databases[id]
|
|
if db == nil {
|
|
return errors.New("no such database")
|
|
}
|
|
if st.DatabasePerm(u.ID, id) != store.PermAdmin {
|
|
return errForbidden
|
|
}
|
|
if cerr := confirmDeletion(st, u, db.Name, password, totpCode, typed); cerr != nil {
|
|
return cerr
|
|
}
|
|
name = db.Name
|
|
// Taken before the delete: the people to tell are the ones who are
|
|
// about to stop having access.
|
|
watchers = st.Watchers(id)
|
|
delete(st.Databases, id)
|
|
st.Grants = filterGrants(st.Grants, func(g store.Grant) bool { return g.DatabaseID != id })
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if ferr := s.blobs.Forget(id); ferr != nil {
|
|
s.log.Printf("delete blobs for %s: %v", id, ferr)
|
|
}
|
|
s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: id})
|
|
s.log.Printf("%s deleted database %q (%s) and every stored version", u.Username, name, id)
|
|
return name, nil
|
|
}
|
|
|
|
func (s *Server) handleDeleteDatabase(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
var req struct {
|
|
Password string `json:"password"`
|
|
TOTP string `json:"totp"`
|
|
Confirm string `json:"confirm_name"`
|
|
}
|
|
if r.ContentLength > 0 && !readJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if _, err := s.deleteDatabase(u, r.PathValue("id"), req.Password, req.TOTP, req.Confirm); err != nil {
|
|
denyAuth(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
}
|
|
|
|
// ---- groups and membership: API ----
|
|
|
|
type groupView struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
ParentID string `json:"parent_id,omitempty"`
|
|
Admin bool `json:"admin"`
|
|
Members int `json:"members"`
|
|
}
|
|
|
|
func (s *Server) handleListGroups(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
out := []groupView{}
|
|
s.st.Read(func(st *store.State) {
|
|
counts := map[string]int{}
|
|
for _, m := range st.Memberships {
|
|
counts[m.GroupID]++
|
|
}
|
|
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 })
|
|
writeJSON(w, http.StatusOK, map[string]any{"groups": out})
|
|
}
|
|
|
|
func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
ParentID string `json:"parent_id"`
|
|
}
|
|
if !readJSON(w, r, &req) {
|
|
return
|
|
}
|
|
var out groupView
|
|
err := s.st.Update(func(st *store.State) error {
|
|
g, err := createGroup(st, u, req.Name, req.ParentID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out = groupView{ID: g.ID, Name: g.Name, ParentID: g.ParentID, Admin: true}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
deny(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, out)
|
|
}
|
|
|
|
func (s *Server) handleDeleteGroup(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
id := r.PathValue("id")
|
|
if err := s.st.Update(func(st *store.State) error {
|
|
return deleteGroup(st, u, id)
|
|
}); err != nil {
|
|
deny(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
}
|
|
|
|
func (s *Server) handleSetMember(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
groupID := r.PathValue("id")
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
UserID string `json:"user_id"`
|
|
Role store.Role `json:"role"`
|
|
}
|
|
if !readJSON(w, r, &req) {
|
|
return
|
|
}
|
|
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
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
groupID, userRef := r.PathValue("id"), r.PathValue("user")
|
|
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: API ----
|
|
|
|
func (s *Server) handleSetGrant(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
var req struct {
|
|
GroupID string `json:"group_id"`
|
|
DatabaseID string `json:"database_id"`
|
|
Perm store.Perm `json:"perm"`
|
|
}
|
|
if !readJSON(w, r, &req) {
|
|
return
|
|
}
|
|
var watchers []string
|
|
err := s.st.Update(func(st *store.State) error {
|
|
if err := setGrant(st, u, req.GroupID, req.DatabaseID, req.Perm); err != nil {
|
|
return err
|
|
}
|
|
watchers = st.Watchers(req.DatabaseID)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
deny(w, err)
|
|
return
|
|
}
|
|
s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: req.DatabaseID})
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) handleDeleteGrant(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
groupID := r.URL.Query().Get("group_id")
|
|
dbID := r.URL.Query().Get("database_id")
|
|
var watchers []string
|
|
err := s.st.Update(func(st *store.State) error {
|
|
// 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)
|
|
return removeGrant(st, u, groupID, dbID)
|
|
})
|
|
if err != nil {
|
|
deny(w, err)
|
|
return
|
|
}
|
|
s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: dbID})
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
}
|
|
|
|
// ---- invites: API ----
|
|
|
|
func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
var req struct {
|
|
GroupID string `json:"group_id"`
|
|
Role store.Role `json:"role"`
|
|
TTLHours int `json:"ttl_hours"`
|
|
MaxUses int `json:"max_uses"`
|
|
}
|
|
if !readJSON(w, r, &req) {
|
|
return
|
|
}
|
|
var inv *store.Invite
|
|
err := s.st.Update(func(st *store.State) error {
|
|
var err error
|
|
inv, err = createInvite(st, u, req.GroupID, req.Role, req.TTLHours, req.MaxUses)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
deny(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"token": inv.Token,
|
|
"url": s.inviteURL(inv.Token),
|
|
"expires_at": inv.ExpiresAt,
|
|
"max_uses": inv.MaxUses,
|
|
"group_id": inv.GroupID,
|
|
"role": inv.Role,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleListInvites(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
type inviteView struct {
|
|
Token string `json:"token"`
|
|
URL string `json:"url"`
|
|
GroupID string `json:"group_id,omitempty"`
|
|
Role store.Role `json:"role"`
|
|
Uses int `json:"uses"`
|
|
MaxUses int `json:"max_uses"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
}
|
|
out := []inviteView{}
|
|
s.st.Read(func(st *store.State) {
|
|
for _, inv := range st.Invites {
|
|
if !mayManageInvite(st, u, inv) {
|
|
continue
|
|
}
|
|
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})
|
|
}
|
|
})
|
|
sort.Slice(out, func(i, j int) bool { return out[i].ExpiresAt.Before(out[j].ExpiresAt) })
|
|
writeJSON(w, http.StatusOK, map[string]any{"invites": out})
|
|
}
|
|
|
|
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 _, target := range visibleUsers(st, u) {
|
|
out = append(out, view(target))
|
|
}
|
|
})
|
|
writeJSON(w, http.StatusOK, map[string]any{"users": out})
|
|
}
|
|
|
|
func (s *Server) handlePatchUser(w http.ResponseWriter, r *http.Request, u *store.User) {
|
|
var req struct {
|
|
Action accountAction `json:"action"`
|
|
}
|
|
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.
|
|
func (s *Server) notifyList(dbID string) {
|
|
var watchers []string
|
|
s.st.Read(func(st *store.State) { watchers = st.Watchers(dbID) })
|
|
s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: dbID})
|
|
}
|
|
|
|
func filterGrants(in []store.Grant, keep func(store.Grant) bool) []store.Grant {
|
|
out := in[:0]
|
|
for _, g := range in {
|
|
if keep(g) {
|
|
out = append(out, g)
|
|
}
|
|
}
|
|
return out
|
|
}
|