feat(server/m7-user-mgmt): admin endpoints (invites, users, promote/demote)
Five admin endpoints under existing RequireAdmin middleware:
- GET /api/admin/invites — list active + recently-redeemed
- POST /api/admin/invites — generate 24h invite, returns
{token, expires_at, ...}. Audits ActionInviteCreate.
- DELETE /api/admin/invites/{token} — revoke unredeemed invite.
Audits ActionInviteRevoke.
- GET /api/admin/users — list all users (id, username,
display_name, is_admin, created_at).
- PUT /api/admin/users/{id}/admin — toggle is_admin with
last-admin guard. Audits ActionPromoteAdmin / ActionDemoteAdmin.
Last-admin guard counts admins, refuses demotion of the sole
admin with 409 'last_admin'. Race window between count and update
is acceptable for v1 — worst case is 'no admins left,' which the
env-driven bootstrap or CLI reset can recover from. Common path
('admin demotes themselves') is now blocked.
Adds ListUsers, CountAdmins, UpdateUserAdmin sqlc queries to
users.sql.
Tests cover: invite create/list/delete round-trip, non-admin gets
403, user list, promote happy path, last-admin demotion refused,
two-admins demotion allowed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user