Skip to content

Protect HTTP routes

wardn’s middleware has the standard func(http.Handler) http.Handler shape, so it works with net/http, chi, and any router that uses it. There is no adapter to install.

r := chi.NewRouter()
r.Use(authenticate) // yours: puts the user ID in the context with wardn.WithUser
r.With(w.Require("report:export")).Get("/reports", exportReports)
r.Route("/branches/{branchID}", func(r chi.Router) {
r.Use(w.ScopeFrom("branch", wardn.PathValue("branchID")))
r.With(w.Require("loan:create")).Post("/loans", createLoan)
r.With(w.RequireAny("loan:view", "loan:approve")).Get("/loans", listLoans)
})
Middleware Lets the request through when the user holds
Require(a, b, …) all of the permissions
RequireAny(a, b, …) at least one of the permissions

ScopeFrom(scopeType, id) tells wardn where the request acts. id is a func(*http.Request) string that returns the scope ID; wardn.PathValue(name) is the usual one, but any function works, for example one that builds a composite ID. wardn.PathValue(name) reads a path parameter, through Go’s standard r.PathValue. Both net/http’s ServeMux and chi (v5.1+) fill it in.

  • Place ScopeFrom after routing. With chi, put it inside r.Route or r.With, where the parameter exists. Placed too early, the ID is empty and every request gets 400.
  • The innermost ScopeFrom wins. An organisation route can contain branch routes, each setting its own scope.
  • Without ScopeFrom, a route checks at global, where only global assignments apply. That’s safe, but usually not what a tenant route wants.
Situation Status Handler runs?
Allowed your handler’s status yes
No user in the context 401 no
Missing permission 403 no
Invalid or missing scope ID in the URL 400 no
Database error 500 no

Error bodies are generic JSON, such as {"error":"forbidden"}, with nosniff and no-store headers. They never reveal which permission or role was missing. The details go to your audit hook.

wardn.Config{
// Read the user from somewhere other than the context:
UserFrom: func(r *http.Request) (string, bool) { return sessionUser(r) },
// Write your own error responses:
OnError: func(w http.ResponseWriter, r *http.Request, err error) {
status := wardn.HTTPStatus(err) // 401, 403, 400 or 500
var denied *wardn.DeniedError
if errors.As(err, &denied) {
log.Info("denied", "decisions", denied.Decisions)
}
writeProblemJSON(w, status)
},
}

Require with an undefined permission, and ScopeFrom with an undeclared scope type, panic when the route is registered:

wardn: permission "loan:aprove" is not defined; did you mean "loan:approve"? (in Require)

The application never starts with a broken route.

The most common real-world authorization bug is a route someone forgot to protect. wardntest.AuditRoutes calls every route as a signed-in user with no roles and fails the test for any route that doesn’t answer 403. See Testing.

Questions about this page?

Ask below, point out something wrong or unclear, or suggest an improvement. Reading needs no account; to post, sign in with GitHub.