diff --git a/internal/api/admin_invites.go b/internal/api/admin_invites.go new file mode 100644 index 00000000..6117f594 --- /dev/null +++ b/internal/api/admin_invites.go @@ -0,0 +1,158 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/audit" + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// inviteTTL is the lifetime of a freshly-minted invite. 24 hours +// matches the spec; admin can revoke an unredeemed invite at any +// time anyway, so a short TTL is just defense-in-depth against +// shared-link leakage. +const inviteTTL = 24 * time.Hour + +type inviteResp struct { + Token string `json:"token"` + InvitedBy string `json:"invited_by"` + Note *string `json:"note"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at"` + RedeemedAt *string `json:"redeemed_at"` + RedeemedBy *string `json:"redeemed_by"` +} + +type listInvitesResp struct { + Invites []inviteResp `json:"invites"` +} + +func (h *handlers) handleListInvites(w http.ResponseWriter, r *http.Request) { + q := dbq.New(h.pool) + rows, err := q.ListInvitesActive(r.Context()) + if err != nil { + h.logger.Error("admin: list invites failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + out := make([]inviteResp, 0, len(rows)) + for _, row := range rows { + out = append(out, inviteFromRow(row)) + } + writeJSON(w, http.StatusOK, listInvitesResp{Invites: out}) +} + +type createInviteReq struct { + Note string `json:"note"` +} + +func (h *handlers) handleCreateInvite(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "auth_required", "") + return + } + var req createInviteReq + // Body is optional — empty body is fine; ignore decode errors. + if r.Body != nil && r.ContentLength != 0 { + _ = json.NewDecoder(r.Body).Decode(&req) + } + + tokenBytes := make([]byte, 16) + if _, err := rand.Read(tokenBytes); err != nil { + h.logger.Error("admin: invite token gen failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + token := hex.EncodeToString(tokenBytes) + expiresAt := time.Now().Add(inviteTTL) + + q := dbq.New(h.pool) + var notePtr *string + if req.Note != "" { + n := req.Note + notePtr = &n + } + row, err := q.CreateInvite(r.Context(), dbq.CreateInviteParams{ + Token: token, + InvitedBy: user.ID, + Note: notePtr, + ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true}, + }) + if err != nil { + h.logger.Error("admin: create invite failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + + if err := audit.Write(r.Context(), h.pool, user.ID, pgtype.UUID{}, audit.ActionInviteCreate, + map[string]any{"token": token}); err != nil { + h.logger.Warn("admin: invite-create audit failed", "err", err) + } + + writeJSON(w, http.StatusOK, inviteFromRow(row)) +} + +func (h *handlers) handleDeleteInvite(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "auth_required", "") + return + } + token := chi.URLParam(r, "token") + if token == "" { + writeErr(w, http.StatusBadRequest, "invalid_token", "") + return + } + q := dbq.New(h.pool) + // Verify the invite exists + is unredeemed before deleting, so we can + // return a useful 404 vs 200. + if _, err := q.GetInviteByToken(r.Context(), token); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + writeErr(w, http.StatusNotFound, "not_found", "") + return + } + h.logger.Error("admin: get invite failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + if err := q.DeleteInvite(r.Context(), token); err != nil { + h.logger.Error("admin: delete invite failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + if err := audit.Write(r.Context(), h.pool, user.ID, pgtype.UUID{}, audit.ActionInviteRevoke, + map[string]any{"token": token}); err != nil { + h.logger.Warn("admin: invite-revoke audit failed", "err", err) + } + w.WriteHeader(http.StatusNoContent) +} + +func inviteFromRow(row dbq.UserInvite) inviteResp { + resp := inviteResp{ + Token: row.Token, + InvitedBy: uuidToString(row.InvitedBy), + Note: row.Note, + CreatedAt: row.CreatedAt.Time.UTC().Format(time.RFC3339), + ExpiresAt: row.ExpiresAt.Time.UTC().Format(time.RFC3339), + } + if row.RedeemedAt.Valid { + s := row.RedeemedAt.Time.UTC().Format(time.RFC3339) + resp.RedeemedAt = &s + } + if row.RedeemedBy.Valid { + s := uuidToString(row.RedeemedBy) + resp.RedeemedBy = &s + } + return resp +} diff --git a/internal/api/admin_invites_test.go b/internal/api/admin_invites_test.go new file mode 100644 index 00000000..6723a770 --- /dev/null +++ b/internal/api/admin_invites_test.go @@ -0,0 +1,111 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" +) + +func newAdminInvitesRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Route("/api/admin", func(admin chi.Router) { + admin.Use(auth.RequireAdmin()) + admin.Get("/invites", h.handleListInvites) + admin.Post("/invites", h.handleCreateInvite) + admin.Delete("/invites/{token}", h.handleDeleteInvite) + }) + return r +} + +func TestAdminInvites_CreateListDelete(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + admin := seedUser(t, pool, "invadmin", "pw", true) + + // Create + req := httptest.NewRequest(http.MethodPost, "/api/admin/invites", nil) + req = withUser(req, admin) + rec := httptest.NewRecorder() + newAdminInvitesRouter(h).ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("create status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var created struct { + Token string `json:"token"` + ExpiresAt string `json:"expires_at"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatalf("decode: %v", err) + } + if created.Token == "" { + t.Fatal("created.Token is empty") + } + + // List + req = httptest.NewRequest(http.MethodGet, "/api/admin/invites", nil) + req = withUser(req, admin) + rec = httptest.NewRecorder() + newAdminInvitesRouter(h).ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("list status = %d, want 200", rec.Code) + } + var listed struct { + Invites []struct{ Token string } `json:"invites"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &listed); err != nil { + t.Fatalf("decode: %v", err) + } + found := false + for _, inv := range listed.Invites { + if inv.Token == created.Token { + found = true + } + } + if !found { + t.Errorf("created invite not in list response") + } + + // Delete + req = httptest.NewRequest(http.MethodDelete, "/api/admin/invites/"+created.Token, nil) + req = withUser(req, admin) + rec = httptest.NewRecorder() + newAdminInvitesRouter(h).ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Errorf("delete status = %d, want 204", rec.Code) + } + + // Verify gone + var count int + if err := pool.QueryRow(context.Background(), + "SELECT count(*) FROM user_invites WHERE token = $1", created.Token).Scan(&count); err != nil { + t.Fatalf("verify: %v", err) + } + if count != 0 { + t.Errorf("invite still exists after delete (count=%d)", count) + } +} + +func TestAdminInvites_NonAdmin_403(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "invnonadmin", "pw", false) + + req := httptest.NewRequest(http.MethodPost, "/api/admin/invites", nil) + req = withUser(req, user) + rec := httptest.NewRecorder() + newAdminInvitesRouter(h).ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403", rec.Code) + } +} diff --git a/internal/api/admin_users.go b/internal/api/admin_users.go new file mode 100644 index 00000000..b67236e9 --- /dev/null +++ b/internal/api/admin_users.go @@ -0,0 +1,128 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/audit" + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +type adminUserView struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName *string `json:"display_name"` + IsAdmin bool `json:"is_admin"` + CreatedAt string `json:"created_at"` +} + +type listUsersResp struct { + Users []adminUserView `json:"users"` +} + +func (h *handlers) handleAdminListUsers(w http.ResponseWriter, r *http.Request) { + q := dbq.New(h.pool) + rows, err := q.ListUsers(r.Context()) + if err != nil { + h.logger.Error("admin: list users failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + out := make([]adminUserView, 0, len(rows)) + for _, row := range rows { + v := adminUserView{ + ID: uuidToString(row.ID), + Username: row.Username, + DisplayName: row.DisplayName, + IsAdmin: row.IsAdmin, + CreatedAt: row.CreatedAt.Time.UTC().Format(time.RFC3339), + } + out = append(out, v) + } + writeJSON(w, http.StatusOK, listUsersResp{Users: out}) +} + +type updateUserAdminReq struct { + IsAdmin bool `json:"is_admin"` +} + +func (h *handlers) handleUpdateUserAdmin(w http.ResponseWriter, r *http.Request) { + caller, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "auth_required", "") + return + } + targetID, ok := parseUUID(chi.URLParam(r, "id")) + if !ok { + writeErr(w, http.StatusBadRequest, "invalid_id", "") + return + } + var req updateUserAdminReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid_body", "") + return + } + + q := dbq.New(h.pool) + + // Last-admin guard: if demoting (is_admin=false) AND target is + // currently the only admin, refuse. The check has a race window + // (between counting and updating, another admin could be demoted) + // but the worst-case outcome is "no admins left," which the + // operator can recover from via the env-driven bootstrap or the + // CLI password-reset path. The check still prevents the common + // path of "admin demotes themselves and loses access." + if !req.IsAdmin { + count, err := q.CountAdmins(r.Context()) + if err != nil { + h.logger.Error("admin: count admins failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + // We need to know whether the target is currently an admin. + // If they're already not-admin, the demote is a no-op (we + // allow it). If they ARE admin and they're the last one, refuse. + current, err := q.GetUserByID(r.Context(), targetID) + if err != nil { + h.logger.Error("admin: get user failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + if current.IsAdmin && count <= 1 { + writeErr(w, http.StatusConflict, "last_admin", + "can't remove the last admin — promote someone else first") + return + } + } + + updated, err := q.UpdateUserAdmin(r.Context(), dbq.UpdateUserAdminParams{ + ID: targetID, + IsAdmin: req.IsAdmin, + }) + if err != nil { + h.logger.Error("admin: update user admin failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + + action := audit.ActionPromoteAdmin + if !req.IsAdmin { + action = audit.ActionDemoteAdmin + } + if err := audit.Write(r.Context(), h.pool, caller.ID, targetID, action, nil); err != nil { + h.logger.Warn("admin: promote/demote audit failed", "err", err) + } + + writeJSON(w, http.StatusOK, adminUserView{ + ID: uuidToString(updated.ID), + Username: updated.Username, + DisplayName: updated.DisplayName, + IsAdmin: updated.IsAdmin, + CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339), + }) +} diff --git a/internal/api/admin_users_test.go b/internal/api/admin_users_test.go new file mode 100644 index 00000000..9cd576fa --- /dev/null +++ b/internal/api/admin_users_test.go @@ -0,0 +1,125 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" +) + +func newAdminUsersRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Route("/api/admin", func(admin chi.Router) { + admin.Use(auth.RequireAdmin()) + admin.Get("/users", h.handleAdminListUsers) + admin.Put("/users/{id}/admin", h.handleUpdateUserAdmin) + }) + return r +} + +func TestAdminUsers_List(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + admin := seedUser(t, pool, "listadmin", "pw", true) + + req := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil) + req = withUser(req, admin) + rec := httptest.NewRecorder() + newAdminUsersRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var resp struct { + Users []struct{ Username string } `json:"users"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Users) == 0 { + t.Errorf("users list is empty; expected at least the seeded admin") + } +} + +func TestAdminUsers_Promote(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + admin := seedUser(t, pool, "promadmin", "pw", true) + target := seedUser(t, pool, "promtarget", "pw", false) + + body := `{"is_admin": true}` + req := httptest.NewRequest(http.MethodPut, "/api/admin/users/"+uuidToString(target.ID)+"/admin", + bytes.NewReader([]byte(body))) + req = withUser(req, admin) + rec := httptest.NewRecorder() + newAdminUsersRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var isAdmin bool + if err := pool.QueryRow(context.Background(), + "SELECT is_admin FROM users WHERE id = $1", target.ID).Scan(&isAdmin); err != nil { + t.Fatalf("verify: %v", err) + } + if !isAdmin { + t.Errorf("target not promoted") + } +} + +func TestAdminUsers_Demote_LastAdmin_Refused(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + // Reset to ensure exactly one admin exists. + if _, err := pool.Exec(context.Background(), "DELETE FROM users"); err != nil { + t.Fatalf("reset: %v", err) + } + soleAdmin := seedUser(t, pool, "soleadmin", "pw", true) + + body := `{"is_admin": false}` + req := httptest.NewRequest(http.MethodPut, "/api/admin/users/"+uuidToString(soleAdmin.ID)+"/admin", + bytes.NewReader([]byte(body))) + req = withUser(req, soleAdmin) + rec := httptest.NewRecorder() + newAdminUsersRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusConflict { + t.Errorf("status = %d, want 409 (last_admin guard)", rec.Code) + } +} + +func TestAdminUsers_Demote_OtherAdmin_Allowed(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + if _, err := pool.Exec(context.Background(), "DELETE FROM users"); err != nil { + t.Fatalf("reset: %v", err) + } + caller := seedUser(t, pool, "caller", "pw", true) + target := seedUser(t, pool, "targetadmin", "pw", true) + + body := `{"is_admin": false}` + req := httptest.NewRequest(http.MethodPut, "/api/admin/users/"+uuidToString(target.ID)+"/admin", + bytes.NewReader([]byte(body))) + req = withUser(req, caller) + rec := httptest.NewRecorder() + newAdminUsersRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200 (two admins exist; demoting one is fine); body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/api/api.go b/internal/api/api.go index 25d13675..3140266c 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -122,6 +122,12 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev admin.Get("/library/coverage", h.handleGetLibraryCoverage) + admin.Get("/invites", h.handleListInvites) + admin.Post("/invites", h.handleCreateInvite) + admin.Delete("/invites/{token}", h.handleDeleteInvite) + admin.Get("/users", h.handleAdminListUsers) + admin.Put("/users/{id}/admin", h.handleUpdateUserAdmin) + admin.Get("/cover-sources", h.handleListCoverSources) admin.Patch("/cover-sources/{provider_id}", h.handleUpdateCoverSource) admin.Post("/cover-sources/{provider_id}/test", h.handleTestCoverSource) diff --git a/internal/db/dbq/users.sql.go b/internal/db/dbq/users.sql.go index 4d778e6f..8c12b9b2 100644 --- a/internal/db/dbq/users.sql.go +++ b/internal/db/dbq/users.sql.go @@ -11,6 +11,17 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const countAdmins = `-- name: CountAdmins :one +SELECT count(*) FROM users WHERE is_admin = true +` + +func (q *Queries) CountAdmins(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, countAdmins) + var count int64 + err := row.Scan(&count) + return count, err +} + const countUsers = `-- name: CountUsers :one SELECT count(*) FROM users ` @@ -205,6 +216,47 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, return i, err } +const listUsers = `-- name: ListUsers :many +SELECT id, username, display_name, is_admin, created_at + FROM users + ORDER BY created_at DESC +` + +type ListUsersRow struct { + ID pgtype.UUID + Username string + DisplayName *string + IsAdmin bool + CreatedAt pgtype.Timestamptz +} + +// Admin user-management list. Sort newest-first. +func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) { + rows, err := q.db.Query(ctx, listUsers) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListUsersRow + for rows.Next() { + var i ListUsersRow + if err := rows.Scan( + &i.ID, + &i.Username, + &i.DisplayName, + &i.IsAdmin, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const setListenBrainzEnabled = `-- name: SetListenBrainzEnabled :exec UPDATE users SET listenbrainz_enabled = $2 @@ -253,3 +305,35 @@ func (q *Queries) SetSubsonicPassword(ctx context.Context, arg SetSubsonicPasswo _, err := q.db.Exec(ctx, setSubsonicPassword, arg.ID, arg.SubsonicPassword) return err } + +const updateUserAdmin = `-- name: UpdateUserAdmin :one +UPDATE users + SET is_admin = $2 + WHERE id = $1 +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name +` + +type UpdateUserAdminParams struct { + ID pgtype.UUID + IsAdmin bool +} + +// Sets is_admin to the given value. Returns the updated row so the +// handler can echo it back to the caller. +func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams) (User, error) { + row := q.db.QueryRow(ctx, updateUserAdmin, arg.ID, arg.IsAdmin) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.ApiToken, + &i.IsAdmin, + &i.CreatedAt, + &i.SubsonicPassword, + &i.ListenbrainzToken, + &i.ListenbrainzEnabled, + &i.DisplayName, + ) + return i, err +} diff --git a/internal/db/queries/users.sql b/internal/db/queries/users.sql index c2ef76fa..10553629 100644 --- a/internal/db/queries/users.sql +++ b/internal/db/queries/users.sql @@ -53,6 +53,23 @@ UPDATE users SET listenbrainz_enabled = $2 WHERE id = $1; +-- name: ListUsers :many +-- Admin user-management list. Sort newest-first. +SELECT id, username, display_name, is_admin, created_at + FROM users + ORDER BY created_at DESC; + +-- name: CountAdmins :one +SELECT count(*) FROM users WHERE is_admin = true; + +-- name: UpdateUserAdmin :one +-- Sets is_admin to the given value. Returns the updated row so the +-- handler can echo it back to the caller. +UPDATE users + SET is_admin = $2 + WHERE id = $1 +RETURNING *; + -- name: GetListenBrainzConfig :one -- Returns the user's LB token + enabled flag and the most recent -- play_events.scrobbled_at for last-scrobbled-at status.