Files
keeporg/internal/server/admin.go
T
ReactorcoremeltdownandClaude Opus 5.5 f8f634fbf7 Add an Account page for sign-in methods and signed-in devices
/web/account, offered to everyone, lists every browser and client signed
in as you, with when each was last used, and signs any of them out, or all
but the current one. It sets up, replaces and removes the second factor,
and holds the password form, moved from the vault page. Administrators can
sign a user out everywhere from the People page.

The API gains GET and DELETE /api/v1/auth/sessions and DELETE
/api/v1/auth/totp. Sessions are named by a digest of their token, never the
token, and record when they were last used. Replacing or removing an active
second factor now needs the password and a current code.

Server version 0.10.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:37:47 +01:00

825 lines
24 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"
accountSignOut accountAction = "sign-out"
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 accountSignOut:
// For a lost phone or laptop: every device must sign in again,
// and the account stays usable.
revokeSessions(st, target.ID)
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
}