Make the admin a member with a flag, and drop the second listener

The admin was a set of env credentials on its own loopback listener. That
bought network isolation, and charged a second port to tunnel and proxy and
a second credential in the password manager. It also sat outside the
SameSite protection the member cookie already had, and left every ban and
password reset with no actor to log.

is_admin on users reuses what was already there: the session, the login
rate limiter, ban-drops-sessions, CSRF. /admin is now a route on the member
mux. A member without the flag gets 404 rather than 403 — the pages are
none of their business, and "forbidden" confirms there is something to be
forbidden from.

Registration needs an invite and invites come from /admin, so an empty
database cannot grow its first user. seedAdmin breaks that circle exactly
once, from ADMIN_EMAIL and ADMIN_PASSWORD, and does nothing against a
database that already has users.

An admin cannot ban themselves: banning drops the target's sessions, and
nothing would be left that could undo it.

This reverses decision 8, which is rewritten rather than deleted, along
with the admin entry in the CONTEXT.md vocabulary.
This commit is contained in:
Esa Kataja
2026-09-05 13:40:26 +03:00
parent 00ea7624ca
commit 2af29fe999
16 changed files with 321 additions and 186 deletions
+63 -4
View File
@@ -25,13 +25,22 @@ func testApp(t *testing.T) *app {
if err := migrate(ctx, db); err != nil {
t.Fatal(err)
}
return &app{cfg: config{adminUser: "admin", adminPass: "s3cret"}, db: db}
return &app{db: db}
}
func post(t *testing.T, h http.Handler, path string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
return postAs(t, h, path, form, "")
}
// postAs is post with a session cookie, which is now the only way to reach an admin route.
func postAs(t *testing.T, h http.Handler, path string, form url.Values, token string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if token != "" {
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: token})
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
@@ -132,6 +141,21 @@ func (a *app) seedMember(t *testing.T, email string) int64 {
return id
}
// seedAdminMember returns an admin account and a live session token for it.
func (a *app) seedAdminMember(t *testing.T, email string) (int64, string) {
t.Helper()
id := a.seedMember(t, email)
if _, err := a.db.ExecContext(context.Background(),
`update users set is_admin = 1 where id = $1`, id); err != nil {
t.Fatal(err)
}
tok, _, err := a.startSession(context.Background(), id, false)
if err != nil {
t.Fatal(err)
}
return id, tok
}
func (a *app) sessionFor(t *testing.T, token string) *member {
t.Helper()
r := httptest.NewRequest("GET", "/", nil)
@@ -202,8 +226,8 @@ func TestBanDropsSessionsAndBlocksLogin(t *testing.T) {
t.Fatal(err)
}
adminMux := a.adminMux()
if w := post(t, adminMux, fmt.Sprintf("/admin/users/%d/ban", id), nil); w.Code != http.StatusSeeOther {
_, adminTok := a.seedAdminMember(t, "[email protected]")
if w := postAs(t, mux, fmt.Sprintf("/admin/users/%d/ban", id), nil, adminTok); w.Code != http.StatusSeeOther {
t.Fatalf("ban: status = %d, want 303", w.Code)
}
@@ -222,7 +246,7 @@ func TestBanDropsSessionsAndBlocksLogin(t *testing.T) {
}
// Reversible: unban, and the same credentials work again.
if w := post(t, adminMux, fmt.Sprintf("/admin/users/%d/ban", id), nil); w.Code != http.StatusSeeOther {
if w := postAs(t, mux, fmt.Sprintf("/admin/users/%d/ban", id), nil, adminTok); w.Code != http.StatusSeeOther {
t.Fatalf("unban: status = %d, want 303", w.Code)
}
w = post(t, mux, "/login", url.Values{"email": {"[email protected]"}, "password": {"salasana1"}})
@@ -231,6 +255,41 @@ func TestBanDropsSessionsAndBlocksLogin(t *testing.T) {
}
}
// Self-banning drops your own sessions, and only an admin could undo it. Refuse.
func TestAdminCannotBanSelf(t *testing.T) {
a := testApp(t)
mux := a.withMember(a.memberMux())
adminID, adminTok := a.seedAdminMember(t, "[email protected]")
if w := postAs(t, mux, fmt.Sprintf("/admin/users/%d/ban", adminID), nil, adminTok); w.Code != http.StatusSeeOther {
t.Fatalf("self-ban: status = %d, want 303", w.Code)
}
var banned bool
if err := a.db.QueryRowContext(context.Background(),
`select banned from users where id = $1`, adminID).Scan(&banned); err != nil {
t.Fatal(err)
}
if banned {
t.Fatal("admin banned themselves")
}
}
// A signed-in member who is not an admin must not be able to ban anyone.
func TestMemberCannotReachAdminRoutes(t *testing.T) {
a := testApp(t)
mux := a.withMember(a.memberMux())
victim := a.seedMember(t, "[email protected]")
plain := a.seedMember(t, "[email protected]")
tok, _, err := a.startSession(context.Background(), plain, false)
if err != nil {
t.Fatal(err)
}
if w := postAs(t, mux, fmt.Sprintf("/admin/users/%d/ban", victim), nil, tok); w.Code != http.StatusNotFound {
t.Fatalf("member ban: status = %d, want 404", w.Code)
}
}
func TestInitials(t *testing.T) {
for name, want := range map[string]string{
"Esa Kataja": "EK",