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) }