From f87ba29836a58548d6109b8b238ed7300ba6f6b4 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:13:51 +0100 Subject: [PATCH] http: requireRole middleware + 403 forbidden page --- internal/server/http/rbac.go | 61 +++++++++++++++++++++++++++++ internal/server/http/rbac_test.go | 63 ++++++++++++++++++++++++++++++ web/templates/pages/forbidden.html | 21 ++++++++++ 3 files changed, 145 insertions(+) create mode 100644 web/templates/pages/forbidden.html diff --git a/internal/server/http/rbac.go b/internal/server/http/rbac.go index 7068f0d..1fc6540 100644 --- a/internal/server/http/rbac.go +++ b/internal/server/http/rbac.go @@ -1,6 +1,9 @@ package http import ( + stdhttp "net/http" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) @@ -24,3 +27,61 @@ func roleAtLeast(have, min store.Role) bool { } return h >= m } + +// requireRole returns chi middleware that 403s any request whose +// session-resolved user doesn't meet the minimum role. Unauthenticated +// requests return 401 (JSON) or 303 → /login (HTML) so the caller +// gets a usable error rather than a confusing 403. +// +// The middleware re-reads the user row on every request — by the time +// you read this you might be tempted to cache; don't. SQLite's WAL +// makes the lookup cheap and admin-driven changes (disable, role +// change) need to land immediately. +func (s *Server) requireRole(min store.Role) func(stdhttp.Handler) stdhttp.Handler { + return func(next stdhttp.Handler) stdhttp.Handler { + return stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u, ok := s.requireUser(r) + if !ok { + if isAPIPath(r) { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") + return + } + stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther) + return + } + if !roleAtLeast(u.Role, min) { + if isAPIPath(r) { + writeJSONError(w, stdhttp.StatusForbidden, "insufficient_role", "") + return + } + renderForbiddenHTML(s, w, r, u, min) + return + } + next.ServeHTTP(w, r) + }) + } +} + +// isAPIPath reports whether the path lives under /api/. Lets one +// middleware return JSON or HTML appropriately without two near- +// identical wrappers. +func isAPIPath(r *stdhttp.Request) bool { + p := r.URL.Path + return len(p) >= 5 && p[:5] == "/api/" +} + +// renderForbiddenHTML emits a small "you don't have permission" +// panel inside the chrome so the user keeps their nav and can +// move away to a page they can see. +func renderForbiddenHTML(s *Server, w stdhttp.ResponseWriter, r *stdhttp.Request, u *store.User, min store.Role) { + w.WriteHeader(stdhttp.StatusForbidden) + view := s.baseView(r, &ui.User{ID: u.ID, Username: u.Username, Role: string(u.Role)}) + view.Title = "Forbidden · restic-manager" + view.Page = struct { + Required string + Have string + }{Required: string(min), Have: string(u.Role)} + if err := s.deps.UI.Render(w, "forbidden", view); err != nil { + _, _ = w.Write([]byte("403 Forbidden — your role does not permit this page.")) + } +} diff --git a/internal/server/http/rbac_test.go b/internal/server/http/rbac_test.go index 5b20b3d..e4b711d 100644 --- a/internal/server/http/rbac_test.go +++ b/internal/server/http/rbac_test.go @@ -1,6 +1,9 @@ package http import ( + stdhttp "net/http" + "net/http/httptest" + "strings" "testing" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" @@ -31,3 +34,63 @@ func TestRoleAtLeast(t *testing.T) { } } } + +func TestRequireRoleViewerAdmits(t *testing.T) { + t.Parallel() + srv, _ := newTestServer(t, false) + uid := makeUser(t, srv, "viewer1", store.RoleViewer) + cookie := loginAs(t, srv, uid) + + mid := srv.requireRole(store.RoleViewer) + h := mid(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, _ *stdhttp.Request) { + w.WriteHeader(stdhttp.StatusOK) + })) + + rr := httptest.NewRecorder() + req, _ := stdhttp.NewRequest("GET", "/api/dummy", nil) + req.AddCookie(cookie) + h.ServeHTTP(rr, req) + if rr.Code != stdhttp.StatusOK { + t.Errorf("status: got %d want 200", rr.Code) + } +} + +func TestRequireRoleViewerRejectedFromOperator(t *testing.T) { + t.Parallel() + srv, _ := newTestServer(t, false) + uid := makeUser(t, srv, "viewer2", store.RoleViewer) + cookie := loginAs(t, srv, uid) + + mid := srv.requireRole(store.RoleOperator) + h := mid(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, _ *stdhttp.Request) { + w.WriteHeader(stdhttp.StatusOK) + })) + + rr := httptest.NewRecorder() + req, _ := stdhttp.NewRequest("GET", "/api/dummy", nil) + req.AddCookie(cookie) + h.ServeHTTP(rr, req) + if rr.Code != stdhttp.StatusForbidden { + t.Errorf("status: got %d want 403", rr.Code) + } + if !strings.Contains(rr.Body.String(), "insufficient_role") { + t.Errorf("body: got %q", rr.Body.String()) + } +} + +func TestRequireRoleUnauthenticated401OnAPI(t *testing.T) { + t.Parallel() + srv, _ := newTestServer(t, false) + + mid := srv.requireRole(store.RoleViewer) + h := mid(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, _ *stdhttp.Request) { + w.WriteHeader(stdhttp.StatusOK) + })) + + rr := httptest.NewRecorder() + req, _ := stdhttp.NewRequest("GET", "/api/dummy", nil) + h.ServeHTTP(rr, req) + if rr.Code != stdhttp.StatusUnauthorized { + t.Errorf("status: got %d want 401", rr.Code) + } +} diff --git a/web/templates/pages/forbidden.html b/web/templates/pages/forbidden.html new file mode 100644 index 0000000..f0e7553 --- /dev/null +++ b/web/templates/pages/forbidden.html @@ -0,0 +1,21 @@ +{{define "title"}}Forbidden · restic-manager{{end}} + +{{define "content"}} +{{$page := .Page}} +
+
+ Dashboard/ + forbidden +
+
+
403 — Insufficient role
+

+ Your role ({{$page.Have}}) does not permit + this page ({{$page.Required}} required). + Ask your administrator if you need access. +

+ Back to dashboard +
+
+{{end}}