http: requireRole middleware + 403 forbidden page

This commit is contained in:
2026-05-05 09:13:51 +01:00
parent 2ba561410f
commit 529104b8e4
3 changed files with 145 additions and 0 deletions
+61
View File
@@ -1,6 +1,9 @@
package http package http
import ( import (
stdhttp "net/http"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store" "gitea.dcglab.co.uk/steve/restic-manager/internal/store"
) )
@@ -24,3 +27,61 @@ func roleAtLeast(have, min store.Role) bool {
} }
return h >= m 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."))
}
}
+63
View File
@@ -1,6 +1,9 @@
package http package http
import ( import (
stdhttp "net/http"
"net/http/httptest"
"strings"
"testing" "testing"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store" "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)
}
}
+21
View File
@@ -0,0 +1,21 @@
{{define "title"}}Forbidden · restic-manager{{end}}
{{define "content"}}
{{$page := .Page}}
<div class="max-w-[1280px] mx-auto px-8 pb-14">
<div class="crumbs pt-6">
<a href="/">Dashboard</a><span class="sep">/</span>
<span class="text-ink-mid">forbidden</span>
</div>
<div class="panel mt-8 rounded-[7px] p-8 max-w-[640px]"
style="border-color: color-mix(in oklch, var(--bad), transparent 60%);">
<div class="text-[14px] font-medium text-bad mb-2">403 — Insufficient role</div>
<p class="text-pretty text-[12.5px] text-ink-mute leading-[1.6]">
Your role (<span class="mono">{{$page.Have}}</span>) does not permit
this page (<span class="mono">{{$page.Required}}</span> required).
Ask your administrator if you need access.
</p>
<a href="/" class="btn btn-primary mt-5">Back to dashboard</a>
</div>
</div>
{{end}}