Files
paliad/internal/auth/require_admin.go
m c697fe3418 feat(admin): /admin/team page + admin-only user CRUD (t-paliad-050)
- New auth.RequireAdmin middleware (gates by paliad.users.role='admin')
  with API/browser-aware reject paths and a fail-closed lookup-error 500.
- Service: AdminCreateUser (onboard from existing auth.users), AdminUpdate
  (full profile fields incl. additional_offices), AdminDeleteUser (also
  removes project_teams + department_members memberships and clears any
  led-Dezernat seat — auth.users is left intact), ListUnonboardedAuthUsers,
  IsAdmin (implements auth.AdminLookup).
- Handlers: GET/POST /api/admin/users, GET /api/admin/users/unonboarded,
  PATCH/DELETE /api/admin/users/{id}, plus GET /admin/team for the page.
  All registered through RequireAdminFunc so non-admins get 403/302.
- Refuses to delete the last remaining admin and rejects role='admin'
  assignment via the admin UI (still SQL-only) — same rules as PATCH /api/me.
- /admin/team page: full users table with inline edit (display_name, office,
  role, dezernat, additional_offices, lang), trash with confirm, search +
  office filters, "Onboard existing account" modal driven by
  /api/admin/users/unonboarded, and an Invite button that re-opens the
  shared sidebar invite modal.
- Sidebar gains a hidden Admin section that sidebar.ts reveals after a
  successful /api/me lookup confirms role='admin' (fails closed on error).
- DE+EN i18n strings for the page, modal and table.
- Tests: require_admin_test.go covers admin-allowed, non-admin 403/302,
  unauthenticated 401 and lookup-error fail-closed paths.
2026-04-27 13:40:00 +02:00

70 lines
2.5 KiB
Go

package auth
import (
"context"
"net/http"
"github.com/google/uuid"
)
// AdminLookup is the minimal interface RequireAdmin needs to consult the
// caller's paliad.users row. Implemented by services.UserService — kept as an
// interface here so the auth package doesn't import services (which would be
// a layering inversion: services depends on auth, not the other way around).
type AdminLookup interface {
IsAdmin(ctx context.Context, userID uuid.UUID) (bool, error)
}
// RequireAdmin wraps a handler so only callers whose paliad.users row has
// role='admin' may proceed. Anyone else gets 403 (JSON for /api/*, redirect
// to /dashboard for browser paths).
//
// Must run downstream of Client.Middleware + Client.WithUserID — the user's
// UUID is read from the request context that those populate.
//
// If the lookup itself errors, the request is rejected with 500 rather than
// fail-open: an admin-gated endpoint that silently lets non-admins through
// when the DB blips is the worst possible failure mode.
func RequireAdmin(lookup AdminLookup) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid, ok := UserIDFromContext(r.Context())
if !ok {
rejectAdmin(w, r, http.StatusUnauthorized, "authentication required")
return
}
ok, err := lookup.IsAdmin(r.Context(), uid)
if err != nil {
rejectAdmin(w, r, http.StatusInternalServerError, "internal error")
return
}
if !ok {
rejectAdmin(w, r, http.StatusForbidden, "admin access required")
return
}
next.ServeHTTP(w, r)
})
}
}
// RequireAdminFunc is the http.HandlerFunc-flavoured wrapper, since most of
// the protected mux is registered with HandleFunc.
func RequireAdminFunc(lookup AdminLookup, h http.HandlerFunc) http.HandlerFunc {
wrapped := RequireAdmin(lookup)(h)
return wrapped.ServeHTTP
}
func rejectAdmin(w http.ResponseWriter, r *http.Request, status int, msg string) {
if isAPIRequest(r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
// Hand-rolled to avoid pulling encoding/json for one constant payload.
_, _ = w.Write([]byte(`{"error":"` + msg + `"}`))
return
}
// Browser path: send the user back to /dashboard with a flash-style query
// param the page can pick up if it wants to surface the message. Avoids
// rendering a bare 403 the user has no obvious way to recover from.
http.Redirect(w, r, "/dashboard?forbidden=admin", http.StatusFound)
}