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
+50 -12
View File
@@ -29,7 +29,8 @@ cmd/keeporg/ desktop client: login, run, sync, status, administration
internal/store/ domain model, JSON metadata store, ACL resolution internal/store/ domain model, JSON metadata store, ACL resolution
internal/blob/ versioned KDBX payload storage on disk internal/blob/ versioned KDBX payload storage on disk
internal/kdbx/ KDBX 4 codec and the merge engine, with its own crypto internal/kdbx/ KDBX 4 codec and the merge engine, with its own crypto
internal/server/ HTTP surface: auth, OIDC, sync, admin, SSE hub, web, pages internal/server/ HTTP surface: auth, OIDC, sync, admin, SSE hub, web,
web administration, page templates
internal/client/ shared client engine: API wrapper, merge policy, mirror internal/client/ shared client engine: API wrapper, merge policy, mirror
internal/secret/ password verifiers, tokens, identifiers internal/secret/ password verifiers, tokens, identifiers
internal/totp/ RFC 6238 second factor internal/totp/ RFC 6238 second factor
@@ -343,18 +344,54 @@ expected request sequence.
## The web interface ## The web interface
The server has one browsable page set, at `/web`, and it exists for the one `/web` is the browsable half of the server. It covers what a command line
job a command line does badly: creating a vault and setting its password. does badly — creating a vault and setting its password, and the daily
Sign in, see which vaults you can reach, create one. It never shows a vault's administration of people — and nothing else. It never shows a vault's
contents — the server opens a vault only to merge it — and there is no contents: the server opens a vault only to merge it, and there is no entry
entry browsing, by design. browsing, by design.
| Page | What it does |
| --- | --- |
| `/web/vaults` | the vaults you can reach, the create-a-vault form, your own account password |
| `/web/groups` | the group tree you can see, and a form to add a group inside one you administer |
| `/web/groups/{id}` | one group: members and their roles, the vaults it reaches and where that access comes from, grants given and revoked, deletion |
| `/web/users` | the accounts you may see; for a server administrator, disable, enable, clear the second factor, promote, demote, delete |
| `/web/invites` | outstanding invite links with their use counts and expiry, a form to make one, a button to revoke one |
The navigation offers the last three only to somebody who administers a
group; a plain member sees their vaults and nothing else. The group selector
only offers groups the signed-in user administers, and a user who
administers none is told so instead of being shown a form that would fail.
**One set of rules behind both interfaces.** Every administrative change is
a function in `internal/server/admin.go` taking the state and the caller —
`createGroup`, `setMember`, `setGrant`, `createInvite`, `revokeInvite`,
`adminAccount` and the rest. The JSON handlers and the HTML forms are both
thin wrappers over them. Had the pages re-implemented the checks, the two
interfaces would have drifted apart on the first change, and that drift would
have been a permission bug rather than a cosmetic one.
**Who may do what.** Group administration follows the tree: administering a
group means administering everything inside it, so the member and grant forms
appear only on groups you administer, and their selectors only offer vaults
you administer. Account changes are deliberately not part of that. They are
server administrator only: a group administrator decides who reaches which
vault, which is access, while disabling an account, clearing somebody's
second factor or deleting them reaches past the group they happen to share.
The three changes that would lock the caller out — disable, demote, delete on
their own account — are refused outright, because a server with no reachable
administrator can only be repaired with `keeporgd -reset-admin` on the host.
Browser sessions are the same session tokens the API uses, carried in a Browser sessions are the same session tokens the API uses, carried in a
cookie that is `HttpOnly`, `SameSite=Strict`, `Path=/`, and `Secure` whenever cookie that is `HttpOnly`, `SameSite=Strict`, `Path=/`, and `Secure` whenever
`external_url` is https. Every form carries a per-session CSRF token that is `external_url` is https. Every page is served through `webPage` and every
checked on each POST. The group selector only offers groups the signed-in form through `webForm`: they resolve the cookie, and a form additionally has
user administers, and a user who administers none is told so instead of being its per-session CSRF token checked before the handler runs. A new form cannot
shown a form that would fail. forget that check, because the check is not in the handler.
No page carries any JavaScript. Each control is its own small form with the
CSRF token and one action in it, which is what makes the interface work in a
locked-down browser and keeps its attack surface down to that token.
Creating a vault is one transaction: the server builds an empty KDBX 4 Creating a vault is one transaction: the server builds an empty KDBX 4
database, encrypts it with the password from the form, stores it as version database, encrypts it with the password from the form, stores it as version
@@ -484,8 +521,9 @@ keeporg invite -group <engineering-id> -role member -uses 5 -hours 72
keeporg grant -group <readers-id> -db <db-id> -perm read keeporg grant -group <readers-id> -db <db-id> -perm read
``` ```
Or create the vault at `https://keeporg.example.com/web`, which is the same Or do all of it at `https://keeporg.example.com/web`: `/web/groups` builds
operation with a form around it. the tree and fills it, `/web/invites` issues the links, `/web/vaults` creates
the vault. The pages call the same functions these commands call.
`keeporg vault` creates the KDBX file itself, so the vault has content from `keeporg vault` creates the KDBX file itself, so the vault has content from
the start: it appears in every member's mirror directory and opens with the the start: it appears in every member's mirror directory and opens with the
+9
View File
@@ -121,10 +121,19 @@ the group named or any group above it.
| DELETE | `/api/v1/grants` | `?group_id=&database_id=` | database admin | | DELETE | `/api/v1/grants` | `?group_id=&database_id=` | database admin |
| POST | `/api/v1/invites` | `{group_id, role, ttl_hours, max_uses}` | group admin | | POST | `/api/v1/invites` | `{group_id, role, ttl_hours, max_uses}` | group admin |
| GET | `/api/v1/invites` | – | your own invites, and those of groups you administer | | GET | `/api/v1/invites` | – | your own invites, and those of groups you administer |
| DELETE | `/api/v1/invites/{token}` | – | whoever may see it: its creator, an admin of its group, a server admin |
| GET | `/api/v1/users` | – | yourself, plus members of groups you administer | | GET | `/api/v1/users` | – | yourself, plus members of groups you administer |
| PATCH | `/api/v1/users/{id}` | `{action}` | server admin |
| GET | `/healthz` | – | unauthenticated liveness | | GET | `/healthz` | – | unauthenticated liveness |
`merge_strategy` is one of `merge`, `server-wins`, `client-wins`, `manual`. `merge_strategy` is one of `merge`, `server-wins`, `client-wins`, `manual`.
Switching to `merge` requires a stored key and is otherwise refused with Switching to `merge` requires a stored key and is otherwise refused with
`400`. `perm` is one of `read`, `write`, `admin`. `role` is `member` or `400`. `perm` is one of `read`, `write`, `admin`. `role` is `member` or
`admin`. `admin`.
An account `action` is one of `disable`, `enable`, `promote`, `demote`,
`clear-totp` or `delete`. `disable` and `delete` also revoke every session
that account holds. The three that would lock the caller out — `disable`,
`demote`, `delete` — are refused on the caller's own account, because a
server with no reachable administrator can only be repaired with
`keeporgd -reset-admin` on the host.
+413 -196
View File
@@ -13,6 +13,12 @@ import (
var errForbidden = errors.New("forbidden") 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. // deny maps a domain error onto a response.
func deny(w http.ResponseWriter, err error) { func deny(w http.ResponseWriter, err error) {
switch { 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 ---- // ---- databases ----
func (s *Server) handleCreateDatabase(w http.ResponseWriter, r *http.Request, u *store.User) { 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"}) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
} }
// ---- groups and membership ---- // ---- groups and membership: API ----
type groupView struct { type groupView struct {
ID string `json:"id"` 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 { for _, m := range st.Memberships {
counts[m.GroupID]++ counts[m.GroupID]++
} }
reach := st.EffectiveGroups(u.ID) for id := range visibleGroups(st, u) {
for id, g := range st.Groups { g := st.Groups[id]
admin := st.IsGroupAdmin(u.ID, id) out = append(out, groupView{ID: id, Name: g.Name, ParentID: g.ParentID,
visible := admin || reach[id] || u.ServerAdmin Admin: st.IsGroupAdmin(u.ID, id), Members: counts[id]})
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]})
}
} }
}) })
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) 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 var out groupView
err := s.st.Update(func(st *store.State) error { err := s.st.Update(func(st *store.State) error {
if strings.TrimSpace(req.Name) == "" { g, err := createGroup(st, u, req.Name, req.ParentID)
return errors.New("name is required") 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} out = groupView{ID: g.ID, Name: g.Name, ParentID: g.ParentID, Admin: true}
return nil 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) { func (s *Server) handleDeleteGroup(w http.ResponseWriter, r *http.Request, u *store.User) {
id := r.PathValue("id") id := r.PathValue("id")
err := s.st.Update(func(st *store.State) error { if err := s.st.Update(func(st *store.State) error {
g := st.Groups[id] return deleteGroup(st, u, id)
if g == nil { }); err != 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 {
deny(w, err) deny(w, err)
return return
} }
@@ -261,37 +555,9 @@ func (s *Server) handleSetMember(w http.ResponseWriter, r *http.Request, u *stor
if !readJSON(w, r, &req) { if !readJSON(w, r, &req) {
return return
} }
err := s.st.Update(func(st *store.State) error { if err := s.st.Update(func(st *store.State) error {
if st.Groups[groupID] == nil { return setMember(st, u, groupID, req.Role, req.UserID, req.Username)
return errors.New("no such group") }); err != nil {
}
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 {
deny(w, err) deny(w, err)
return 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) { func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request, u *store.User) {
groupID, userRef := r.PathValue("id"), r.PathValue("user") groupID, userRef := r.PathValue("id"), r.PathValue("user")
err := s.st.Update(func(st *store.State) error { if err := s.st.Update(func(st *store.State) error {
if st.Groups[groupID] == nil { return removeMember(st, u, groupID, userRef)
return errors.New("no such group") }); err != nil {
}
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 {
deny(w, err) deny(w, err)
return return
} }
writeJSON(w, http.StatusOK, map[string]string{"status": "removed"}) 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) { func (s *Server) handleSetGrant(w http.ResponseWriter, r *http.Request, u *store.User) {
var req struct { var req struct {
@@ -344,27 +588,9 @@ func (s *Server) handleSetGrant(w http.ResponseWriter, r *http.Request, u *store
} }
var watchers []string var watchers []string
err := s.st.Update(func(st *store.State) error { err := s.st.Update(func(st *store.State) error {
if st.Databases[req.DatabaseID] == nil { if err := setGrant(st, u, req.GroupID, req.DatabaseID, req.Perm); err != nil {
return errors.New("no such database") 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) watchers = st.Watchers(req.DatabaseID)
return nil 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") dbID := r.URL.Query().Get("database_id")
var watchers []string var watchers []string
err := s.st.Update(func(st *store.State) error { err := s.st.Update(func(st *store.State) error {
if st.Databases[dbID] == nil { // The watcher list is taken before the grant goes, because the people
return errors.New("no such database") // losing access are exactly the ones who must be told.
}
if st.DatabasePerm(u.ID, dbID) != store.PermAdmin {
return errForbidden
}
watchers = st.Watchers(dbID) watchers = st.Watchers(dbID)
st.Grants = filterGrants(st.Grants, func(g store.Grant) bool { return removeGrant(st, u, groupID, dbID)
return g.GroupID != groupID || g.DatabaseID != dbID
})
return nil
}) })
if err != nil { if err != nil {
deny(w, err) 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"}) 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) { func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request, u *store.User) {
var req struct { var req struct {
@@ -413,36 +632,11 @@ func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request, u *s
if !readJSON(w, r, &req) { if !readJSON(w, r, &req) {
return return
} }
inv := &store.Invite{Token: secret.Token(), GroupID: req.GroupID, Role: req.Role, var inv *store.Invite
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)
err := s.st.Update(func(st *store.State) error { err := s.st.Update(func(st *store.State) error {
if inv.GroupID == "" { var err error
if !u.ServerAdmin { inv, err = createInvite(st, u, req.GroupID, req.Role, req.TTLHours, req.MaxUses)
return errors.New("group_id is required") return err
}
} 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
}) })
if err != nil { if err != nil {
deny(w, err) 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{ writeJSON(w, http.StatusCreated, map[string]any{
"token": inv.Token, "token": inv.Token,
"url": s.cfg.ExternalURL + "/invite/" + inv.Token, "url": s.inviteURL(inv.Token),
"expires_at": inv.ExpiresAt, "expires_at": inv.ExpiresAt,
"max_uses": inv.MaxUses, "max_uses": inv.MaxUses,
"group_id": inv.GroupID, "group_id": inv.GroupID,
@@ -471,11 +665,10 @@ func (s *Server) handleListInvites(w http.ResponseWriter, r *http.Request, u *st
out := []inviteView{} out := []inviteView{}
s.st.Read(func(st *store.State) { s.st.Read(func(st *store.State) {
for _, inv := range st.Invites { for _, inv := range st.Invites {
if !u.ServerAdmin && inv.CreatedBy != u.ID && if !mayManageInvite(st, u, inv) {
!(inv.GroupID != "" && st.IsGroupAdmin(u.ID, inv.GroupID)) {
continue 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, GroupID: inv.GroupID, Role: inv.Role, Uses: inv.Uses, MaxUses: inv.MaxUses,
ExpiresAt: inv.ExpiresAt}) 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}) 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) { func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request, u *store.User) {
out := []userView{} out := []userView{}
s.st.Read(func(st *store.State) { s.st.Read(func(st *store.State) {
for id, target := range st.Users { for _, target := range visibleUsers(st, u) {
if u.ServerAdmin || id == u.ID || s.sharesAdministeredGroup(st, u.ID, id) { out = append(out, view(target))
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}) writeJSON(w, http.StatusOK, map[string]any{"users": out})
} }
// sharesAdministeredGroup reports whether admin administers any group the func (s *Server) handlePatchUser(w http.ResponseWriter, r *http.Request, u *store.User) {
// target belongs to, which is what makes the target visible to them. var req struct {
func (s *Server) sharesAdministeredGroup(st *store.State, adminID, targetID string) bool { Action accountAction `json:"action"`
for _, m := range st.Memberships {
if m.UserID == targetID && st.IsGroupAdmin(adminID, m.GroupID) {
return true
}
} }
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. // notifyList tells everyone with access that the database list changed.
+39 -19
View File
@@ -5,9 +5,12 @@ import (
"net/http" "net/http"
) )
// The server has no browsable UI. These three pages exist only because a // The pages: sign-in, the vault list, the administration pages in
// browser is the right place to approve a device, redeem an invite and run an // pagesadmin.go, and the three one-shot pages a browser is the right place
// OIDC round trip. // 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(` var pages = template.Must(template.New("layout").Parse(`
{{define "layout"}}<!doctype html> {{define "layout"}}<!doctype html>
<html lang="en"><head><meta charset="utf-8"> <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} :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}} @media(prefers-color-scheme:dark){:root{--fg:#e8e8e8;--bg:#161616;--card:#1f1f1f;--line:#3a3a3a;--accent:#74c69d}}
*{box-sizing:border-box} *{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} font:16px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;padding:24px}
body.wide{place-items:start center} 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} body.wide main{max-width:54rem}
h1{font-size:1.25rem;margin:0 0 .25rem} h1{font-size:1.25rem;margin:0 0 .25rem}
p.sub{margin:0 0 1.25rem;opacity:.7;font-size:.9rem} 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} 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} 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} .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(` 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(` var vaultsTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{define "body"}} {{define "body"}}
<header> <h1>Your vaults</h1>
<div><h1>Your vaults</h1><p class="sub">{{.User}}</p></div> <p class="sub">The files your KeepOrg client keeps in sync.</p>
<form method="post" action="/web/signout">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button type="submit">Sign out</button>
</form>
</header>
{{if .Error}}<p class="err">{{.Error}}</p>{{end}} {{if .Error}}<p class="err">{{.Error}}</p>{{end}}
{{if .Notice}}<p class="ok">{{.Notice}}</p>{{end}} {{if .Notice}}<p class="ok">{{.Notice}}</p>{{end}}
{{if .Vaults}} {{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> <thead><tr><th>Vault</th><th>Access</th><th>On conflict</th><th>Version</th><th>Last saved</th></tr></thead>
<tbody> <tbody>
{{range .Vaults}}<tr> {{range .Vaults}}<tr>
@@ -121,7 +142,7 @@ var vaultsTmpl = template.Must(template.Must(pages.Clone()).Parse(`
<td>{{.Version}}</td> <td>{{.Version}}</td>
<td>{{.Updated}}{{if .By}}<br><small>by {{.By}}</small>{{end}}</td> <td>{{.Updated}}{{if .By}}<br><small>by {{.By}}</small>{{end}}</td>
</tr>{{end}} </tr>{{end}}
</tbody></table> </tbody></table></div>
{{else}} {{else}}
<p class="empty">No vaults yet. Create one below, then open it from the KeepOrg client <p class="empty">No vaults yet. Create one below, then open it from the KeepOrg client
on your computer or phone.</p> 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) { func (s *Server) renderVaults(w http.ResponseWriter, sess *webSession, errMsg, notice string) {
vaults, groups := s.vaultRows(sess.user) vaults, groups := s.vaultRows(sess.user)
render(w, vaultsTmpl, map[string]any{ d := s.shell(sess, "Vaults", "Vaults")
"Title": "Vaults", "Wide": true, "User": sess.user.Username, "CSRF": sess.csrf, d["Vaults"], d["Groups"], d["Error"], d["Notice"] = vaults, groups, errMsg, notice
"Vaults": vaults, "Groups": groups, "Error": errMsg, "Notice": notice, render(w, vaultsTmpl, d)
})
} }
func (s *Server) renderDone(w http.ResponseWriter, title, msg string) { 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/start", s.handleDeviceStart)
m.HandleFunc("POST /api/v1/auth/device/poll", s.handleDevicePoll) 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) { m.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/web", http.StatusFound) http.Redirect(w, r, "/web", http.StatusFound)
}) })
m.HandleFunc("GET /web", s.handleWebHome) m.HandleFunc("GET /web", s.handleWebHome)
m.HandleFunc("POST /web/signin", s.handleWebSignIn) m.HandleFunc("POST /web/signin", s.handleWebSignIn)
m.HandleFunc("POST /web/signout", s.handleWebSignOut) m.HandleFunc("POST /web/signout", s.webForm(s.handleWebSignOut))
m.HandleFunc("GET /web/vaults", s.handleWebVaults) m.HandleFunc("GET /web/vaults", s.webPage(s.handleWebVaults))
m.HandleFunc("POST /web/vaults", s.handleWebCreateVault) m.HandleFunc("POST /web/vaults", s.webForm(s.handleWebCreateVault))
m.HandleFunc("POST /web/password", s.handleWebChangePassword) 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. // Browser-facing pages: device approval, invite registration, OIDC.
m.HandleFunc("GET /device", s.handleDevicePage) 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("DELETE /api/v1/grants", s.authed(s.handleDeleteGrant))
m.HandleFunc("POST /api/v1/invites", s.authed(s.handleCreateInvite)) m.HandleFunc("POST /api/v1/invites", s.authed(s.handleCreateInvite))
m.HandleFunc("GET /api/v1/invites", s.authed(s.handleListInvites)) 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("GET /api/v1/users", s.authed(s.handleListUsers))
m.HandleFunc("PATCH /api/v1/users/{id}", s.authed(s.handlePatchUser))
} }
// ---- request plumbing ---- // ---- request plumbing ----
+43 -41
View File
@@ -11,9 +11,10 @@ import (
"cc.tiredsysadmin.keeporg/internal/store" "cc.tiredsysadmin.keeporg/internal/store"
) )
// The web interface exists for one job the command line cannot do well: // The web interface covers what a browser is better at than the command
// creating a vault and setting its password in a browser. It is not a // line: creating a vault and setting its password, and the administration of
// password manager and never shows a vault's contents; the server does not // 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. // open a vault except to merge one.
const webCookie = "keeporg_web" const webCookie = "keeporg_web"
@@ -44,6 +45,39 @@ func (s *Server) webUser(r *http.Request) *webSession {
return out 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. // checkCSRF verifies the form token against the session's.
func (s *Server) checkCSRF(r *http.Request, sess *webSession) bool { func (s *Server) checkCSRF(r *http.Request, sess *webSession) bool {
return sess.csrf != "" && secret.Equal(sess.csrf, r.FormValue("csrf")) 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) { func (s *Server) handleWebSignOut(w http.ResponseWriter, r *http.Request, _ *webSession) {
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
}
if c, err := r.Cookie(webCookie); err == nil { if c, err := r.Cookie(webCookie); err == nil {
_ = s.st.Update(func(st *store.State) error { _ = s.st.Update(func(st *store.State) error {
delete(st.Sessions, c.Value) delete(st.Sessions, c.Value)
@@ -131,31 +156,17 @@ type webVault struct {
HasKey bool HasKey bool
} }
// webGroup is one option in the owning-group selector. // webGroup is one option in a group selector.
type webGroup struct { type webGroup struct {
ID string ID string
Name string Name string
} }
func (s *Server) handleWebVaults(w http.ResponseWriter, r *http.Request) { func (s *Server) handleWebVaults(w http.ResponseWriter, r *http.Request, sess *webSession) {
sess := s.webUser(r)
if sess == nil {
s.renderSignIn(w, "")
return
}
s.renderVaults(w, sess, "", "") s.renderVaults(w, sess, "", "")
} }
func (s *Server) handleWebCreateVault(w http.ResponseWriter, r *http.Request) { func (s *Server) handleWebCreateVault(w http.ResponseWriter, r *http.Request, sess *webSession) {
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
}
password := r.FormValue("password") password := r.FormValue("password")
if password != r.FormValue("password2") { if password != r.FormValue("password2") {
s.renderVaults(w, sess, "The two vault passwords do not match.", "") 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.") "with the password you just set.")
} }
func (s *Server) handleWebChangePassword(w http.ResponseWriter, r *http.Request) { func (s *Server) handleWebChangePassword(w http.ResponseWriter, r *http.Request, sess *webSession) {
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
}
next := r.FormValue("new_password") next := r.FormValue("new_password")
if next != r.FormValue("new_password2") { if next != r.FormValue("new_password2") {
s.renderVaults(w, sess, "The two new passwords do not match.", "") 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. // humanError keeps internal wording out of the browser.
func humanError(err error) string { func humanError(err error) string {
if errors.Is(err, errForbidden) { if errors.Is(err, errForbidden) {
return "You do not administer that group." return "You do not administer that."
} }
return err.Error() 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)
}
}
}
+7
View File
@@ -1,3 +1,10 @@
keeporgd (0.6.0) unstable; urgency=medium
* Add the administration pages: groups and their members, accounts,
invites and vault grants from a browser
-- tiredsysadmin <rcmd@garage.rcmd.space> Fri, 30 Oct 2026 12:28:50 +0100
keeporgd (0.5.0) unstable; urgency=medium keeporgd (0.5.0) unstable; urgency=medium
* Single sign-on now works from the web interface. The link on the * Single sign-on now works from the web interface. The link on the
+1 -1
View File
@@ -1,6 +1,6 @@
# Single source of truth for all platform versions (semver). # Single source of truth for all platform versions (semver).
# Bump with: make bump PLATFORM=<server|linux|macos|windows|bsd|android> PART=<major|minor|patch> # Bump with: make bump PLATFORM=<server|linux|macos|windows|bsd|android> PART=<major|minor|patch>
VERSION_SERVER := 0.5.0 VERSION_SERVER := 0.6.0
VERSION_LINUX := 0.3.0 VERSION_LINUX := 0.3.0
VERSION_MACOS := 0.3.0 VERSION_MACOS := 0.3.0
VERSION_WINDOWS := 0.3.0 VERSION_WINDOWS := 0.3.0