diff --git a/coordinator/internal/transport/http/server.go b/coordinator/internal/transport/http/server.go index ecd44a3..c5daa44 100644 --- a/coordinator/internal/transport/http/server.go +++ b/coordinator/internal/transport/http/server.go @@ -129,6 +129,9 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler { for _, rt := range app { ui.Handle(rt.pattern, gate(rt.handler)) } + // Admin panel: session + admin role. + ui.Handle("GET /ui/admin", chain(http.HandlerFunc(s.handleUIAdmin), gate, requireAdmin)) + ui.Handle("POST /ui/admin/user-action", chain(http.HandlerFunc(s.handleUIAdminUserAction), gate, requireAdmin)) } else { for _, rt := range app { ui.HandleFunc(rt.pattern, rt.handler) diff --git a/coordinator/internal/transport/http/templates/admin.html b/coordinator/internal/transport/http/templates/admin.html new file mode 100644 index 0000000..8bff008 --- /dev/null +++ b/coordinator/internal/transport/http/templates/admin.html @@ -0,0 +1,46 @@ +{{define "admin.html"}} + + + + + + Admin · SciMesh + + + +
+
+

Admin panel

User & run control

+
← Dashboard
+
+

Signed in as {{.Role}}. Promote or verify a user by their id, and control every job from the dashboard.

+ + {{if .Msg}}
{{.Msg}}
{{end}} + {{if .Error}}
{{.Error}}
{{end}} + +
+

Manage a user

+

Paste the user id (the JWT sub / the value shown at registration). Actions are applied immediately.

+
+ + +

Promote makes them an admin; Verify marks them a trusted contributor (their workers skip quorum).

+
+ + + + +
+
+
+ +
+

Jobs & tasks

+

As an admin you already see every user's jobs on the dashboard, with per-task status and job cancellation. A regular user sees only their own.

+ +
+
+ + +{{end}} diff --git a/coordinator/internal/transport/http/templates/dashboard.html b/coordinator/internal/transport/http/templates/dashboard.html index de530f3..2381cf8 100644 --- a/coordinator/internal/transport/http/templates/dashboard.html +++ b/coordinator/internal/transport/http/templates/dashboard.html @@ -13,7 +13,7 @@

Local scientific compute

SciMesh control room

Follow the real path from a molecular TSV to a globally reduced similarity result—without reading coordinator logs.

Live overview · refreshes every 2 seconds
-
{{if .Session}}Signed in · {{.Session.Role}}{{end}}+ New similarity search{{if .Session}}
{{end}}
+
{{if .Session}}Signed in · {{.Session.Role}}{{end}}{{if and .Session (eq .Session.Role "admin")}}Admin{{end}}+ New similarity search{{if .Session}}
{{end}}
How a search becomes a result
01Upload TSVThe coordinator validates and slices the dataset.
02Run shardsWorkers fingerprint molecules and return shard top-k CSVs.
03Merge exactlyThe coordinator ranks retained candidates deterministically.
04Download CSVA checksum-protected global result is ready.
diff --git a/coordinator/internal/transport/http/ui_admin.go b/coordinator/internal/transport/http/ui_admin.go new file mode 100644 index 0000000..5d4e05f --- /dev/null +++ b/coordinator/internal/transport/http/ui_admin.go @@ -0,0 +1,109 @@ +package http + +import ( + "context" + "net/http" + "net/url" + "strings" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/coordinator/internal/authctx" +) + +// adminUserActions are the userservice endpoints the admin panel may invoke, by +// their path suffix. A whitelist so a crafted form can never proxy an arbitrary +// path. +var adminUserActions = map[string]bool{ + "promote": true, + "demote": true, + "verify": true, + "unverify": true, +} + +// requireAdmin gates a route on the session caller being an admin. It runs +// inside withUISession, which has already stamped the requester. A non-admin is +// sent back to the dashboard rather than shown the panel. +func requireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if req, ok := authctx.From(r.Context()); !ok || !req.IsAdmin() { + http.Redirect(w, r, "/ui", http.StatusSeeOther) + return + } + next.ServeHTTP(w, r) + }) +} + +func (s *Server) handleUIAdmin(w http.ResponseWriter, r *http.Request) { + role := "" + if req, ok := authctx.From(r.Context()); ok { + role = req.Role + } + s.renderUI(w, "admin.html", map[string]any{ + "Role": role, + "Msg": r.URL.Query().Get("msg"), + "Error": r.URL.Query().Get("error"), + }) +} + +// handleUIAdminUserAction proxies a user-management action to the userservice, +// forwarding the admin's session token so the userservice re-checks the role. +// The user id and action come from the form, so a single static form action can +// drive every operation. +func (s *Server) handleUIAdminUserAction(w http.ResponseWriter, r *http.Request) { + userID := strings.TrimSpace(r.FormValue("user_id")) + action := r.FormValue("action") + + if !adminUserActions[action] { + http.Redirect(w, r, "/ui/admin?error=unknown+action", http.StatusSeeOther) + return + } + if _, err := uuid.Parse(userID); err != nil { + http.Redirect(w, r, "/ui/admin?error=invalid+user+id", http.StatusSeeOther) + return + } + c, err := r.Cookie(sessionCookie) + if err != nil { + redirectToLogin(w, r) + return + } + + status, err := s.callUserserviceAuthed(r.Context(), http.MethodPost, "/users/"+userID+"/"+action, c.Value) + if err != nil { + s.log.Error("admin action proxy", "err", err, "action", action) + http.Redirect(w, r, "/ui/admin?error=service+unavailable", http.StatusSeeOther) + return + } + switch status { + case http.StatusNoContent: + http.Redirect(w, r, "/ui/admin?msg="+url.QueryEscape(action+" applied"), http.StatusSeeOther) + case http.StatusNotFound: + http.Redirect(w, r, "/ui/admin?error=user+not+found", http.StatusSeeOther) + case http.StatusForbidden, http.StatusUnauthorized: + http.Redirect(w, r, "/ui/admin?error=not+authorized", http.StatusSeeOther) + default: + http.Redirect(w, r, "/ui/admin?error=action+failed", http.StatusSeeOther) + } +} + +// callUserserviceAuthed makes an authenticated call to the userservice, passing +// the caller's JWT through as a bearer token. Used for admin actions; login and +// registration use the unauthenticated callUserservice. +func (s *Server) callUserserviceAuthed(ctx context.Context, method, path, bearer string) (int, error) { + // path is not attacker-controlled: the caller composes it only from a + // uuid-validated id and an action from a fixed whitelist, and the host is + // the operator-configured userservice — so the SSRF taint gosec sees here + // cannot reach an arbitrary destination. + req, err := http.NewRequestWithContext(ctx, method, s.userserviceURL+path, nil) //nolint:gosec // G704: path is validated, host is config + if err != nil { + return 0, err + } + req.Header.Set("Authorization", "Bearer "+bearer) + + resp, err := s.httpClient.Do(req) //nolint:gosec // G704: see above + if err != nil { + return 0, err + } + defer func() { _ = resp.Body.Close() }() + return resp.StatusCode, nil +} diff --git a/coordinator/internal/transport/http/ui_admin_internal_test.go b/coordinator/internal/transport/http/ui_admin_internal_test.go new file mode 100644 index 0000000..92f3c27 --- /dev/null +++ b/coordinator/internal/transport/http/ui_admin_internal_test.go @@ -0,0 +1,113 @@ +package http + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/coordinator/internal/authctx" + "github.com/emil28092005/SciMesh/coordinator/internal/usecase" +) + +func adminReq(t *testing.T, role string) *http.Request { + t.Helper() + req := newReq(http.MethodGet, "/ui/admin", nil) + return req.WithContext(authctx.With(context.Background(), authctx.Requester{UserID: uuid.New(), Role: role})) +} + +func TestRequireAdminAllowsAdminOnly(t *testing.T) { + reached := false + h := requireAdmin(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true })) + + // Admin passes through. + h.ServeHTTP(httptest.NewRecorder(), adminReq(t, "admin")) + if !reached { + t.Error("admin must reach the handler") + } + + // Plain user is redirected to the dashboard. + reached = false + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminReq(t, "user")) + if reached { + t.Error("non-admin must not reach the handler") + } + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui" { + t.Errorf("non-admin got %d -> %q, want 303 -> /ui", rec.Code, rec.Header().Get("Location")) + } +} + +func TestAdminUserActionForwardsBearer(t *testing.T) { + targetID := uuid.NewString() + var gotAuth, gotPath string + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotPath = r.URL.Path + w.WriteHeader(http.StatusNoContent) + })) + defer stub.Close() + s := newLoginServer(stub) + + req := newReq(http.MethodPost, "/ui/admin/user-action", + strings.NewReader(url.Values{"user_id": {targetID}, "action": {"promote"}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "admin.jwt.token"}) + rec := httptest.NewRecorder() + s.handleUIAdminUserAction(rec, req) + + if gotAuth != "Bearer admin.jwt.token" { + t.Errorf("forwarded auth = %q, want the admin bearer", gotAuth) + } + if gotPath != "/users/"+targetID+"/promote" { + t.Errorf("forwarded path = %q", gotPath) + } + if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "msg=") { + t.Errorf("got %d -> %q, want 303 with a success msg", rec.Code, rec.Header().Get("Location")) + } +} + +func TestAdminUserActionRejectsUnknownAction(t *testing.T) { + s := newLoginServer(httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("userservice must not be called for an invalid action") + }))) + req := newReq(http.MethodPost, "/ui/admin/user-action", + strings.NewReader(url.Values{"user_id": {uuid.NewString()}, "action": {"delete"}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "x"}) + rec := httptest.NewRecorder() + s.handleUIAdminUserAction(rec, req) + if !strings.Contains(rec.Header().Get("Location"), "error=") { + t.Errorf("unknown action redirect = %q, want an error", rec.Header().Get("Location")) + } +} + +func TestAdminUserActionRejectsBadID(t *testing.T) { + s := newLoginServer(httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("userservice must not be called for an invalid id") + }))) + req := newReq(http.MethodPost, "/ui/admin/user-action", + strings.NewReader(url.Values{"user_id": {"not-a-uuid"}, "action": {"promote"}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "x"}) + rec := httptest.NewRecorder() + s.handleUIAdminUserAction(rec, req) + if !strings.Contains(rec.Header().Get("Location"), "error=") { + t.Errorf("bad id redirect = %q, want an error", rec.Header().Get("Location")) + } +} + +func TestDashboardAdminLinkOnlyForAdmin(t *testing.T) { + admin := render(t, "dashboard.html", usecase.DashboardView{Session: &usecase.SessionView{Role: "admin"}}) + if !strings.Contains(admin, "/ui/admin") { + t.Error("admin must see the Admin link") + } + user := render(t, "dashboard.html", usecase.DashboardView{Session: &usecase.SessionView{Role: "user"}}) + if strings.Contains(user, "/ui/admin") { + t.Error("a plain user must not see the Admin link") + } +}