Files
ReactorcoremeltdownandClaude Opus 5 81094c0228 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>
2026-10-30 12:32:08 +01:00

369 lines
12 KiB
Go

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)
}
}
}