88 lines
2.9 KiB
Go
88 lines
2.9 KiB
Go
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."))
|
|
}
|
|
}
|