- 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.
70 lines
2.5 KiB
Go
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)
|
|
}
|