Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c0d4b28
feat(rbac): check rules against registered routes at startup
akshat-kumar-singhal Sep 26, 2026
4197766
fix(rbac): satisfy goconst and typos in the route coverage check
akshat-kumar-singhal Sep 26, 2026
441fcb6
feat(rbac): stop startup when a registered route has no rule
akshat-kumar-singhal Sep 26, 2026
d732eb6
feat(rbac): log route check failures instead of stopping startup
akshat-kumar-singhal Sep 29, 2026
167eebb
Merge branch 'development' into feat/rbac-route-coverage-3935
aryanmehrotra Sep 30, 2026
f7a96e9
fix(rbac): check route coverage strictly and keep the check out of th…
akshat-kumar-singhal Sep 30, 2026
06e25ce
Merge branch 'development' into feat/rbac-route-coverage-3935
akshat-kumar-singhal Oct 1, 2026
d955000
fix(rbac): report a route as covered only when the check can show it
akshat-kumar-singhal Oct 6, 2026
57a4ef7
Merge branch 'development' into feat/rbac-route-coverage-3935
akshat-kumar-singhal Oct 6, 2026
13b0bbc
Merge branch 'development' into feat/rbac-route-coverage-3935
aryanmehrotra Oct 13, 2026
b806309
refactor(rbac): move the startup route check out of the public API
akshat-kumar-singhal Oct 13, 2026
8843c8f
fix(rbac): report a real route at "/" as "/" on the rbac.authorize span
akshat-kumar-singhal Oct 13, 2026
e4fc649
refactor(rbac): mark GoFr's catch-all once instead of probing the rou…
akshat-kumar-singhal Oct 13, 2026
ad286b7
feat(rbac): check every config EnableRBAC installed, not only the last
akshat-kumar-singhal Oct 13, 2026
5c484cb
Merge remote-tracking branch 'origin/development' into feat/rbac-rout…
akshat-kumar-singhal Oct 14, 2026
ebfaaca
test(rbac): pin the framework catch-all mark; docs: root static dir a…
akshat-kumar-singhal Oct 14, 2026
0472a5d
Merge branch 'development' into feat/rbac-route-coverage-3935
akshat-kumar-singhal Oct 14, 2026
51d79c1
Merge branch 'development' into feat/rbac-route-coverage-3935
aryanmehrotra Oct 14, 2026
9a43ec8
Merge branch 'development' into feat/rbac-route-coverage-3935
akshat-kumar-singhal Oct 16, 2026
93765cd
Merge branch 'development' into feat/rbac-route-coverage-3935
akshat-kumar-singhal Oct 16, 2026
79318fa
Merge branch 'development' into feat/rbac-route-coverage-3935
akshat-kumar-singhal Oct 16, 2026
865e8f7
Merge branch 'development' into feat/rbac-route-coverage-3935
aryanmehrotra Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 143 additions & 5 deletions docs/advanced-guide/rbac/page.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,124 @@ request, so it is NOT enforced - any route it was meant to govern is currently u

Treat that line as an open route, not a warning about a typo.

### Startup Route Check

The RBAC config is a second copy of your route table, written by hand, so the two can drift apart.
When the app starts (inside `app.Run()`, after every route has been registered), GoFr compares
them and logs each kind of mismatch below as an **error**. By default the app keeps starting, so
read the startup logs: each line names a route that is not protected the way the config intends.
[`GOFR_RBAC_ROUTE_CHECK`](#choosing-what-a-mismatch-does) can make a mismatch stop startup instead.

Paths are compared exactly as written, the same way requests are matched: `/api/users/` and
`/api/users` are different paths, and so are `files` and `/files`.

**A dead rule** is a rule that matches no registered route. It is usually a typo — a rule for
`/api/user/{id}` when the route is `/api/users/{id}` — and it means the route it was written for
is **not protected**. A rule counts as dead when no request could be matched by both the rule and
a registered route. A rule whose method the route does not register (`DELETE` on a route that only
has `GET`) is dead too. A dead rule marked `"public": true` cannot leave a route unprotected, so it
is reported only as a warning.

```
RBAC route check failed: rules match no registered route: DELETE /api/user/{id}. These rules
protect nothing; fix each rule's path or methods, or remove it.
```

**An uncovered route** is a registered route, for a given method, that no rule matches, so it would
be served without role checks (see [Unmatched Routes Behavior](#unmatched-routes-behavior)). To
keep a route open on purpose, such as `/login` or a webhook, give it a rule with `"public": true`.
The routes GoFr adds itself — health, alive, `/favicon.ico`, and the OpenAPI, Swagger and GraphQL
Playground pages under `/.well-known/` — are exempt. A route your application registers under
`/.well-known/` is not, and needs a rule like any other. Other routes GoFr registers for you do
need a rule: the `./static` directory (served at `/static` and everything under `/static/`
when it exists) and `/graphql` when GraphQL is enabled. A catch-all needs the `/` in front of it:
a rule for `/admin/{rest:.*}` matches `/admin/users` but not `/admin`, so the route `/admin` needs
a rule of its own.

```
RBAC route check failed: routes covered by no rule: GET /api/posts, POST /api/users. They are
served without role checks; add a rule for each, with "public": true for a route meant to be open.
```

**A partly covered route** is one that rules match for some of its requests but not for all of
them. The requests no rule matches are served without role checks. Common causes:

- a constraint narrower than the route: a rule for `/api/users/{id:[0-9]+}` does not govern
`/api/users/abc`, which the route `/api/users/{id}` serves;
- a method list narrower than the route: a static directory answers every method, so a rule for
`["GET"]` leaves `HEAD /static/app.js` unchecked. Use `["*"]` for static files.

```
RBAC route check failed: routes only partly covered: * /static/{path:.*} (by GET /static/{path:.*}).
Requests these rules do not match are served without role checks; add a rule that matches each
route in full.
```

A rule set that opens the static directory and a login route:

```json
{"path": "/login", "methods": ["POST"], "public": true},
{"path": "/static", "methods": ["*"], "public": true},
{"path": "/static/{path:.*}", "methods": ["*"], "public": true}
Comment thread
aryanmehrotra marked this conversation as resolved.
```

A static directory served at the root, with `app.AddStaticFiles("/", dir)`, is the route
`* /{path:.*}`, and the rule that covers it also matches GoFr's health routes,
`/.well-known/alive` and `/.well-known/health`. The check does not report this, because those
routes are exempt, but a request to them without a role gets a 401, so a liveness probe fails. Open
them with a public rule; the more specific rule wins, whatever order the two are written in:

```json
{"path": "/.well-known/{path:.*}", "methods": ["GET"], "public": true},
{"path": "/{path:.*}", "methods": ["*"], "requiredPermissions": ["files:read"]}
```

The dead-rule check is lenient on purpose: two path variables with different constraints —
`{id:[0-9]+}` in the rule and `{id:[a-z]+}` in the route — are treated as matching, so a rule like
that is not reported as dead. The coverage check is strict: a rule covers a route only when the
check can show that it matches every request the route serves, so the same pair is reported as
partly covered. A rule the check cannot read in full is reported as partly covering its route
rather than trusted, which includes:

- a catch-all followed by more segments, such as `/api/{rest:.*}/admin`: it covers nothing, as the
check does not work out what the catch-all leaves for the segments after it;
- a constraint that can match `/`, such as `{p:[^.]+}`: like `{path:.*}`, it can span several
segments, so a single-segment `{x}` does not cover it;
- a segment holding more than one variable, such as `{a}-{b}`: it is read as the pattern mux builds
from it, not as one free variable.

Constraints are compared as written, the way mux reads them: `{id: [0-9]+}`, with a space, only
matches IDs that start with a space, so it neither matches nor covers `{id:[0-9]+}`.

The check sees the route table, not the middleware in front of it: a request answered before
routing (a CORS preflight, for example) never reaches RBAC either way.

When `EnableRBAC` is called more than once, every config enforces its own rules on each request, so
the check looks at all of them. A route is reported as uncovered or partly covered only when no
config covers it in full. A dead rule is reported for each config that holds it, prefixed with the
config's number in the order `EnableRBAC` was called:

```
RBAC route check failed: rules match no registered route: config 1: GET /amdin/{id}. These rules
protect nothing; fix each rule's path or methods, or remove it.
```

#### Choosing what a mismatch does

Set `GOFR_RBAC_ROUTE_CHECK` in your config:

| Value | Behavior |
|---|---|
| `warn` (default) | Log each mismatch as an error and keep starting. |
| `fail` | Log each mismatch, release what startup opened, and exit with status 1. A dead public rule is only a warning and does not stop startup. |
| `off` | Skip the check. For apps that leave routes without a rule on purpose and rely on [Unmatched Routes Behavior](#unmatched-routes-behavior). |

Any other value is logged as an error and treated as `warn`.

The check runs after the [`OnStart` hooks](/docs/advanced-guide/startup-hooks), because a hook can
still register a route, and before the MCP port is claimed or any server starts. With `fail`, the
datasources are already connected and the hooks have run by the time a mismatch stops startup.

## JWT-Based RBAC

For production/public APIs, use JWT-based role extraction:
Expand Down Expand Up @@ -799,10 +917,11 @@ In this configuration:
- `GET /api/posts` → **Not in RBAC config** → Allowed to proceed (may return 404 if route doesn't exist)
- `GET /health` → **Not in RBAC config** → Allowed to proceed (will work if route exists)

This design allows you to:
- Gradually add RBAC protection to specific endpoints
- Keep some routes unprotected (not in RBAC config)
- Let the router handle 404s for non-existent routes
At startup, the app in this example logs an error for `POST /api/users` and `GET /api/posts`:
they are registered but covered by no rule (see [Startup Route Check](#startup-route-check)).
Give every registered route a rule, and a route meant to be open one with `"public": true`.
The runtime behavior above still applies to a request no rule matches, for example one to a route
the check reported as only partly covered, or to any route when `GOFR_RBAC_ROUTE_CHECK=off`.

## Security and Privacy

Expand All @@ -811,7 +930,11 @@ This design allows you to:
RBAC middleware implements industry-standard security practices to protect sensitive data:

**Traces (OpenTelemetry):**
- ✅ HTTP method and route patterns included
- ✅ HTTP method and route patterns included: `http.route` is the route the router matched, and
`rbac.rule` is the path of the RBAC rule that governed the request (`<unmatched>` when none did).
When the two disagree, the rule is broader or narrower than the route. `http.route` is
`<unmatched>` when no route matched, that is, when GoFr's catch-all answered the request. An
application route registered at `/` is reported as `/`.
- ✅ Authorization status (allowed/denied) included
- ❌ Roles excluded (privacy protection - roles are PII)
- ❌ Error messages sanitized (prevent information leakage)
Expand All @@ -835,6 +958,21 @@ RBAC middleware implements industry-standard security practices to protect sensi
- ❌ No authorization tokens, headers, or request bodies logged
- ❌ No user IDs or personal information logged

#### Upgrade note: `http.route` on the `rbac.authorize` span

Earlier releases set `http.route` on the `rbac.authorize` span to the path of the RBAC rule that
governed the request, or `<unmatched>` when no rule did. It is now the route the router matched, the
same value as on the request's HTTP span, and the old value moved to the new `rbac.rule` attribute.

The value of `http.route` changes in two cases:

- **The rule is broader or narrower than the route.** A request to `GET /api/users/42` governed by
a rule for `/api/{path:.*}` was reported as `/api/{path:.*}` and is now `/api/users/{id}`.
- **No rule matches the request.** It was reported as `<unmatched>` and is now the route template.

Nothing fails when the value changes, so check every dashboard, alert or query that groups the
`rbac.authorize` span by `http.route`: to keep grouping by rule, group by `rbac.rule` instead.

### What's Never Logged

RBAC middleware never logs:
Expand Down
6 changes: 6 additions & 0 deletions docs/references/configs/page.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,12 @@ This document lists all the configuration options supported by the GoFr framewor

---

- GOFR_RBAC_ROUTE_CHECK
- What the startup check does when the RBAC config and the registered routes disagree — see [Startup Route Check](/docs/advanced-guide/rbac#startup-route-check). `warn` logs each mismatch as an error and keeps starting, `fail` logs them and stops startup with exit status 1, `off` skips the check. Any other value is logged as an error and treated as `warn`.
- warn

---

- LOG_DISABLE_PROBES
- Disable log probes for health checks
- false
Expand Down
30 changes: 25 additions & 5 deletions pkg/gofr/gofr.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@ import (
"gofr.dev/pkg/gofr/config"
"gofr.dev/pkg/gofr/container"
gofrHTTP "gofr.dev/pkg/gofr/http"
"gofr.dev/pkg/gofr/internal/rbaccheck"
"gofr.dev/pkg/gofr/internal/routemark"
"gofr.dev/pkg/gofr/logging"
"gofr.dev/pkg/gofr/metrics"
"gofr.dev/pkg/gofr/migration"
"gofr.dev/pkg/gofr/rbac"
"gofr.dev/pkg/gofr/service"
"gofr.dev/pkg/gofr/traces/exporters"
)
Expand Down Expand Up @@ -80,6 +83,15 @@ type App struct {
// same point it reports a port it could not claim. See bindMCPServer.
mcpConfigErr error

// rbacConfigs holds every RBAC config installed by EnableRBAC, in order, kept so Run can check
// them against the complete route table. Each call installs a middleware that enforces its own
// config, so all of them are checked. See prepareHTTPServer.
rbacConfigs []*rbac.Config

// rbacRoutes is the route table as the RBAC route check reads it. httpServerSetup fills it from
// the walk it already makes, and only when rbacConfigs is set.
rbacRoutes []rbaccheck.Route

// exit is os.Exit, indirected so a test can observe the status a failed startup or a failed
// command reports without taking the test binary down with it. Nil means os.Exit, which is what
// every real app uses.
Expand Down Expand Up @@ -226,7 +238,11 @@ func (a *App) httpServerSetup() {
}
}

routes := newRBACRouteCollector(len(a.rbacConfigs) > 0)

// Register default routes - these are only added when HTTP server is actually starting
routes.startBuiltIns(&a.httpServer.router.Router)

a.add(http.MethodGet, service.HealthPath, a.healthHandler)
a.add(http.MethodGet, service.AlivePath, liveHandler)
a.add(http.MethodGet, "/favicon.ico", faviconHandler)
Expand All @@ -239,6 +255,8 @@ func (a *App) httpServerSetup() {
a.add(http.MethodGet, "/.well-known/graphql/ui", playgroundHandler)
}

routes.endBuiltIns(&a.httpServer.router.Router)

for dirName, endpoint := range a.httpServer.staticFiles {
a.httpServer.router.AddStaticFiles(a.Logger(), endpoint, dirName)
}
Expand All @@ -247,11 +265,9 @@ func (a *App) httpServerSetup() {

a.logReadiness()

if a.container.Logger != nil {
a.container.Logger.Infof("Registered HTTP server on port: %d", a.httpServer.port)
}

a.httpServer.router.PathPrefix("/").Handler(handler{
// The one catch-all GoFr registers: marked so the RBAC trace label and route check can tell it
// from a route or static directory at "/", which share its template.
routemark.MarkCatchAll(a.httpServer.router.PathPrefix("/")).Handler(handler{
function: catchAllHandler,
container: a.container,
})
Expand All @@ -266,9 +282,13 @@ func (a *App) httpServerSetup() {
}
}

routes.add(route, met)

return nil
})

a.rbacRoutes = routes.routes

*a.httpServer.router.RegisteredRoutes = registeredMethods
}

Expand Down
7 changes: 5 additions & 2 deletions pkg/gofr/http/alloc_guard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (
// invisible from the outside. So a revert of any of them keeps the suite green
// and gives the allocations back silently. This counts them instead.
//
// The count it pins is 7, while the commit's table quotes 8 allocs/op. They are
// The count it pinned was 7 (now 6, see wantAllocs), while the commit's table quotes 8 allocs/op. They are
// different fixtures, not an inconsistency: this guard drives five middlewares
// and a nopWriter, the benchmark three and a real response writer. The guard's
// job is to notice change, so it is measured where the signal is cleanest rather
Expand All @@ -35,7 +35,10 @@ func TestTrieRequestAllocationsDoNotRegress(t *testing.T) {
// AllocsPerRun is deterministic for fixed code, so the only thing that moves
// this is a Go or dependency upgrade -- worth a human looking at. If it fails
// after one, re-measure and update the constant in the same commit.
const wantAllocs = 7
//
// 6 since the trie stores the matched *mux.Route in the request context instead
// of its template string: a pointer fits in the interface, a string is boxed.
const wantAllocs = 6

r := NewRouter()
// Five middlewares, because that is what newHTTPServer installs and because the
Expand Down
28 changes: 8 additions & 20 deletions pkg/gofr/http/route_context.go
Original file line number Diff line number Diff line change
@@ -1,26 +1,18 @@
package http

import (
"context"
"net/http"

"github.com/gorilla/mux"
)

// routeTemplateCtxKey is the private context key under which the trie router
// stores the matched route template (e.g. "/users/{id}"). It exists because,
// once the trie router bypasses mux's ServeHTTP, mux.CurrentRoute(r) is no
// longer populated, so the template must be carried some other way.
type routeTemplateCtxKey struct{}

// withRouteTemplate returns r carrying tmpl as the matched route template. An
// empty template is a no-op so unmatched requests do not pay for a context copy.
func withRouteTemplate(r *http.Request, tmpl string) *http.Request {
if tmpl == "" {
return r
}
"gofr.dev/pkg/gofr/internal/routemark"
)

return r.WithContext(context.WithValue(r.Context(), routeTemplateCtxKey{}, tmpl))
// withRoute returns r carrying route as the matched route. It exists because, once the trie
// router bypasses mux's ServeHTTP, mux.CurrentRoute(r) is no longer populated, so the route must be
// carried some other way. A nil route is a no-op so unmatched requests do not pay for a context copy.
func withRoute(r *http.Request, route *mux.Route) *http.Request {
return routemark.With(r, route)
}

// RouteTemplate returns the matched route template for r (e.g. "/users/{id}"),
Expand All @@ -31,11 +23,7 @@ func withRouteTemplate(r *http.Request, tmpl string) *http.Request {
// via mux.CurrentRoute. Callers get consistent behavior without knowing which
// router is active.
func RouteTemplate(r *http.Request) string {
if tmpl, _ := r.Context().Value(routeTemplateCtxKey{}).(string); tmpl != "" {
return tmpl
}

if route := mux.CurrentRoute(r); route != nil {
if route := routemark.Matched(r); route != nil {
if tmpl, err := route.GetPathTemplate(); err == nil {
return tmpl
}
Expand Down
Loading
Loading