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" ) // rank maps each role to a numeric tier so 'A is at least B' becomes // 'rank[A] >= rank[B] && both are known'. Unknown roles return 0 → // fail-closed against either argument. var roleRank = map[store.Role]int{ store.RoleViewer: 1, store.RoleOperator: 2, store.RoleAdmin: 3, } // roleAtLeast reports whether `have` meets or exceeds `min` in the // admin > operator > viewer hierarchy. Either side being an unknown // role returns false. func roleAtLeast(have, min store.Role) bool { h, hok := roleRank[have] m, mok := roleRank[min] if !hok || !mok { return false } 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.")) } }