Repository navigation
RBAC: authorize on a token's scope/permissions claim, and add the role/permission extractor hook agreed in #2144 #4388
Description
Is your feature request related to a problem? Please describe.
EnableRBAC decides every request from one role-name string, read from roleHeader or a
single jwtClaimPath, and maps it to permissions through the static roles section of the config.
Most identity providers don't put authority in a token that way. They send it as:
| Claim | Shape | Emitted by (examples) |
|---|---|---|
scope |
space-delimited string (RFC 8693 §4.2, RFC 9068 §2.2.3) | Auth0, Cognito, most OAuth2 servers |
scp |
space-delimited string | Entra ID |
permissions |
array of strings | Auth0 (RBAC "Add Permissions in the Access Token"), Clerk, WorkOS |
roles / groups (RFC 9068 §2.2.3.1) |
array of strings | Entra ID, Keycloak, Zitadel |
None of these can drive GoFr's RBAC today:
jwtClaimPath: "scope" token {"scope":"orders:read orders:write"} -> role = "orders:read orders:write" -> no match -> 403
jwtClaimPath: "roles" token {"roles":["admin","viewer"]} -> role = "[admin viewer]" -> no match -> 403 (#3979 item 3)
Separately, there is no hook to supply the permissions yourself, for example from a live store so a
revoked grant stops working before the token expires. LoadPermissions accepts only a file path,
and role extraction is hard-coded in extractRole.
The result is that an app whose identity provider issues scopes can't use EnableRBAC at all. It
has to write its own authorization middleware, which re-implements the endpoint matching and the
specificity-based rule resolution just fixed in #3934, and it misses future fixes to them.
Describe the solution you'd like
Two additive, backward-compatible changes. Existing configs behave exactly as today.
-
Read a multi-valued claim. When the configured claim holds an array or a space-delimited
string, split it into values instead of formatting it into one string. Two ways to use the
values:- As roles: union the permissions of every named role. This also settles the decision
RBAC: per-request pattern matching races and grows the router without bound; multi-role JWTs authorize nothing #3979 item 3 asks for. - As permissions, directly: a new key (name illustrative), e.g.
"permissionsClaimPath": "scope". The values are matched againstrequiredPermissionswith
today's ANY semantics, and therolessection becomes optional.
- As roles: union the permissions of every named role. This also settles the decision
-
An extractor hook. Let the app supply
func(r *http.Request) (permissions []string, err error), or a roles variant, for anything the
built-in claim reader can't express. The middleware keeps doing endpoint matching, public-route
handling, audit logging and metrics; only "what does this caller hold?" is delegated. An error
from the hook denies the request.This is the shape discussed in RBAC middleware support #2144's review, where a maintainer agreed:
"We can have the interface in GoFr and implementation outside"
(RBAC middleware support #2144 (comment)). The PR merged without
it. Issue RBAC(Role-Based Access Control) Support #2000 also listed "custom extractors" as goal 3.
Since EnableRBAC(configPath ...string) is variadic on a string, the hook probably needs either
an options form (EnableRBAC(path, rbac.WithExtractor(fn))) or a separate constructor. Happy to
follow whatever convention you prefer.
Describe alternatives you've considered
- Writing our own middleware. This is what we do today. It works, but it duplicates the matcher
and resolver, and it gives up the audit log, metrics and tracing the built-in provides. - Only fixing RBAC: per-request pattern matching races and grows the router without bound; multi-role JWTs authorize nothing #3979 item 3 (role arrays). That helps role-list tokens, but not
scope/scp
tokens, where the values are permissions rather than role names. It also doesn't cover a live
lookup. - A custom middleware that rewrites the token into a single-role header before
EnableRBAC
runs. This loses information whenever a caller holds more than one role or scope, and hides
the real authority from the audit log.
Additional context
- Other frameworks with built-in authorization read these claims natively and expose a
customization point. Spring Security'sJwtGrantedAuthoritiesConvertermapsscope/scpto
SCOPE_*authorities and can be replaced; ASP.NET Core hasRequireScope/RequireClaimplus
pluggable authorization handlers. - Related: RBAC(Role-Based Access Control) Support #2000 (original RBAC proposal, goal 3), RBAC middleware support #2144 (review discussion above), RBAC: per-request pattern matching races and grows the router without bound; multi-role JWTs authorize nothing #3979 item 3
(role arrays), RBAC keeps a second copy of the route table; the router has already resolved the request by the time the middleware runs #3935 (RBAC's own route table). - If the direction is acceptable, I'm happy to send a PR.
Activity
akshat-kumar-singhal commentedon Oct 1, 2026
I'd like to start with the first half of this issue, multi-valued claims, and leave the extractor hook for a follow-up. A few design choices need a maintainer call first. Each one has a recommendation, so a short "agree" or "prefer X" on each is enough.
1. Meaning of a role array (#3979 item 3)
For
"jwtClaimPath": "roles"and a token{"roles":["admin","viewer"]}, the claim currently formats to"[admin viewer]", which matches no role, so every request gets a 403.Recommendation: union. The caller gets the permissions of every role it holds. The alternative is to reject an array claim with a clear error.
roles[0]keeps working as it does today.2. Splitting on spaces
"scope": "orders:read orders:write"is space-delimited by spec (RFC 8693 §4.2, RFC 9068 §2.2.3), so it should split. A role name, though, can legitimately contain a space ("Org Admin").Recommendation: split a space-delimited string only for the new permissions claim. For the role claim, expand only real JSON arrays and keep a string as one role name, as today.
3. Both claim keys set
If a config sets both
jwtClaimPath(roles) andpermissionsClaimPath(permissions):Recommendation: reject at load with a clear error. Combining the two gives two sources of authority that are hard to audit. If combining is needed later, it can be added without breaking anything, while the reverse would be a breaking change.
Scope of the PR
Config only, with no Go API change, and it closes #3979 item 3. I'll base it on #4390 once that merges, since both touch
pkg/gofr/rbac/middleware.go.akshat-kumar-singhal commentedon Oct 1, 2026
@aryanmehrotra could you take a look at the three questions above when you get a chance?
aryanmehrotra commentedon Oct 1, 2026
Thanks for asking first. Multi-valued claims now, the hook later: agreed.
Your three questions
How I expect it to be used
{ "jwtClaimPath": "roles", "roles": [{ "name": "viewer", "permissions": ["orders:read"] }], "endpoints": [{ "path": "/orders", "methods": ["GET"], "requiredPermissions": ["orders:read"] }] }{ "permissionsClaimPath": "scope", "audience": ["https://orders.example.com"], "endpoints": [{ "path": "/orders", "methods": ["GET"], "requiredPermissions": ["orders:read"] }] }"roles": ["admin","viewer"]"scope": "orders:read orders:write"audis another APIaudienceEnableRBACreturns an errorEnableRBACreturns an errorDoes this match what you have in mind?
Lets cover these
exp?EnableRBACerror means no authorization. Docs and examples must check it. Should it deny instead? That reverses #4387, so it needs the other maintainers, but I would like your view.EnableRBACbeforeEnableOAuthgives 401 on everything. Warn once.PR checklist
roles[0]unchanged.examples/.development, not feat(rbac): check rules against registered routes at startup #4390.Open questions
.?defaultPolicy: denyfor routes no rule covers #4389, feat(rbac): check rules against registered routes at startup #4390 and this each add one.rbac_role_extraction_failureslooks unregistered. Can you confirm?Not in this PR
Scope AND role together, a live permission store, per-tenant roles, gRPC, ALL-of permissions, and an audience warning for the existing JWT roles mode. The hook goes in its own issue: #2144 was about RBAC as a separate module, and
EnableBasicAuthWithValidatoris the pattern to follow.akshat-kumar-singhal commentedon Oct 5, 2026
Yes, that matches. PR: #4434. Answers to the gaps and open questions:
Audience /
exp: audience is required in permissions mode and lives in the RBAC config.expis required too: a permissions-mode token withoutexpgets a 401. The OAuth middleware already rejects an expiredexp, but it accepts a token that has none, and a scope token that never expires is a standing credential.Bad config: I'd keep #4387 as is.
EnableRBACreturns an error and logs that authorization is DISABLED. Every doc snippet and the new example check the error and stop. Turning it into deny-all would swap a visible startup failure for an app that runs but answers 403 to everything.Audit log / error handler: an allowed request logs only the role or permission that granted it. A denied request logs
held_countand no names, and therolepassed to the error handler is empty. The single-role path (header,"role","roles[0]") logs as before, so existing log consumers keep working.Unreadable claim: 403, plus a WARN naming the claim path (never its value), plus
rbac_role_extraction_failures.Call order: RBAC warns once, on the first request that finds no JWT claims: "call EnableOAuth before EnableRBAC".
Performance: there is no merged list. Role arrays are checked role by role against a precomputed role-to-permission set, and a scope string is walked with
strings.FieldsSeq. A unit test pins that check at 0 allocations. The middleware benchmark gives 3 allocs/op in every mode, the same as the existing single-role header mode (those come from storing the role in the request context); the PR has the numbers.Dotted claim names: the whole path is first tried as one top-level key (
https://example.com/roles), then walked asa.b.cas today.Key names:
permissionsClaimPathandaudience(a list).File or env: the RBAC config file, so all RBAC settings sit in one place, as the existing
roleHeader/jwtClaimPathdo.rbac_role_extraction_failures: confirmed. It was incremented but never registered, and this PR registers it.One addition to flag: when
audienceis set in roles mode it is checked there too, and settingaudiencewith no JWT claim path is a load error, so the setting is never silently ignored. Nothing changes for existing configs. I'll drop it if you'd rather keep audience to permissions mode only.The extractor hook will get its own issue, following
EnableBasicAuthWithValidator.akshat-kumar-singhal commentedon Oct 16, 2026
The first half of this issue shipped in #4434: multi-valued role claims (the union of every named role) and
permissionsClaimPath, which authorizes directly from a scope/permissions claim. Seedocs/advanced-guide/rbac/page.md.The extractor hook continues in #4484, as agreed above ("Multi-valued claims now, the hook later"). Closing this one.