feat(coordinator): admin panel at /ui/admin
Admin-only page to promote/demote/verify/unverify a user by id. Actions proxy to the userservice forwarding the admin's session JWT, which the userservice re-checks (defense in depth). Non-admins are redirected off the panel; the Admin link shows only for admins. Admins already see all jobs on the dashboard. Tests: requireAdmin gate, bearer forwarding, action/id validation, admin link.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
{{define "admin.html"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Admin · SciMesh</title>
|
||||
<style>
|
||||
:root{color:#e5efff;background:#08111f;font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;color-scheme:dark}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 10% -8%,#183f77 0,transparent 32rem),#08111f}.page{max-width:820px;margin:auto;padding:28px 22px 64px}a{color:#94bdff}.top{display:flex;justify-content:space-between;align-items:center;gap:12px}.eyebrow{margin:0;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:6px 0 0;color:#f4f8ff;font-size:clamp(1.8rem,4vw,2.6rem);letter-spacing:-.04em}.lead{max-width:640px;margin:10px 0 0;color:#aabed9}.card{margin-top:24px;border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021;padding:22px}.card h2{margin:0 0 4px;color:#f1f6ff;font-size:1.1rem}.card p{margin:0;color:#9fb3cf;font-size:.92rem}label{display:block;margin:16px 0 5px;color:#eaf2ff;font-weight:750}input{width:100%;border:1px solid #42617f;border-radius:9px;padding:10px 11px;background:#0a1626;color:#e5efff;font:inherit}input:focus{outline:2px solid #5d97f5;outline-offset:1px}.actions{display:flex;flex-wrap:wrap;gap:9px;margin-top:16px}.btn{border:0;border-radius:9px;padding:10px 14px;font:inherit;font-weight:800;cursor:pointer}.btn-primary{background:#67e3b8;color:#062018}.btn-muted{background:#23344d;color:#dce8ff}.notice{margin-top:16px;border-radius:10px;padding:11px 13px;font-weight:700}.ok{background:#123f34;color:#76efb5}.err{background:#552334;color:#ff9bad}.muted{color:#8ba2c2}.hint{margin-top:4px;color:#92a9c6;font-size:.85rem}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<header class="top">
|
||||
<div><p class="eyebrow">Admin panel</p><h1>User & run control</h1></div>
|
||||
<div style="display:flex;gap:10px;align-items:center"><a href="/ui">← Dashboard</a><form method="post" action="/ui/logout" style="margin:0"><button class="btn btn-muted" type="submit">Log out</button></form></div>
|
||||
</header>
|
||||
<p class="lead">Signed in as <strong>{{.Role}}</strong>. Promote or verify a user by their id, and control every job from the dashboard.</p>
|
||||
|
||||
{{if .Msg}}<div class="notice ok">{{.Msg}}</div>{{end}}
|
||||
{{if .Error}}<div class="notice err">{{.Error}}</div>{{end}}
|
||||
|
||||
<section class="card">
|
||||
<h2>Manage a user</h2>
|
||||
<p>Paste the user id (the JWT <code>sub</code> / the value shown at registration). Actions are applied immediately.</p>
|
||||
<form method="post" action="/ui/admin/user-action">
|
||||
<label for="user_id">User id</label>
|
||||
<input id="user_id" name="user_id" placeholder="00000000-0000-0000-0000-000000000000" autocomplete="off" required>
|
||||
<p class="hint">Promote makes them an admin; Verify marks them a trusted contributor (their workers skip quorum).</p>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" name="action" value="promote" type="submit">Make admin</button>
|
||||
<button class="btn btn-muted" name="action" value="demote" type="submit">Remove admin</button>
|
||||
<button class="btn btn-primary" name="action" value="verify" type="submit">Verify</button>
|
||||
<button class="btn btn-muted" name="action" value="unverify" type="submit">Unverify</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Jobs & tasks</h2>
|
||||
<p>As an admin you already see <strong>every user's jobs</strong> on the dashboard, with per-task status and job cancellation. A regular user sees only their own.</p>
|
||||
<div class="actions"><a class="btn btn-muted" href="/ui" style="text-decoration:none">Open the dashboard →</a></div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -13,7 +13,7 @@
|
||||
<main class="page">
|
||||
<header class="top">
|
||||
<div><p class="eyebrow">Local scientific compute</p><h1 class="title">SciMesh control room</h1><p class="lead">Follow the real path from a molecular TSV to a globally reduced similarity result—without reading coordinator logs.</p><div class="live"><i class="pulse"></i><span id="refresh-state">Live overview · refreshes every 2 seconds</span></div></div>
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">{{if .Session}}<span class="live" style="margin-top:0">Signed in · {{.Session.Role}}</span>{{end}}<a class="button" href="/ui/jobs/new">+ New similarity search</a>{{if .Session}}<form method="post" action="/ui/logout" style="margin:0"><button class="button" type="submit" style="background:#23344d;color:#dce8ff;box-shadow:none">Log out</button></form>{{end}}</div>
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">{{if .Session}}<span class="live" style="margin-top:0">Signed in · {{.Session.Role}}</span>{{end}}{{if and .Session (eq .Session.Role "admin")}}<a class="button" href="/ui/admin" style="background:#23344d;color:#dce8ff;box-shadow:none">Admin</a>{{end}}<a class="button" href="/ui/jobs/new">+ New similarity search</a>{{if .Session}}<form method="post" action="/ui/logout" style="margin:0"><button class="button" type="submit" style="background:#23344d;color:#dce8ff;box-shadow:none">Log out</button></form>{{end}}</div>
|
||||
</header>
|
||||
<section class="summary" aria-label="Pipeline summary">
|
||||
<div class="panel"><strong>How a search becomes a result</strong><div class="pipeline"><div class="flow-step"><span><i class="dot"></i>01</span><b>Upload TSV</b><small>The coordinator validates and slices the dataset.</small></div><div class="flow-step"><span><i class="dot"></i>02</span><b>Run shards</b><small>Workers fingerprint molecules and return shard top-k CSVs.</small></div><div class="flow-step"><span><i class="dot"></i>03</span><b>Merge exactly</b><small>The coordinator ranks retained candidates deterministically.</small></div><div class="flow-step"><span><i class="dot"></i>04</span><b>Download CSV</b><small>A checksum-protected global result is ready.</small></div></div></div>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user