Skip to content

RBAC: authorize on a token's scope/permissions claim, and add the role/permission extractor hook agreed in #2144 #4388

Closed

Description

@akshat-kumar-singhal

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.

  1. 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:

  2. 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

Activity

  1. akshat-kumar-singhal commented on Oct 1, 2026

    @akshat-kumar-singhal
    ContributorAuthor

    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) and permissionsClaimPath (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.

  2. akshat-kumar-singhal commented on Oct 1, 2026

    @akshat-kumar-singhal
    ContributorAuthor

    @aryanmehrotra could you take a look at the three questions above when you get a chance?

  3. aryanmehrotra commented on Oct 1, 2026

    @aryanmehrotra
    Member

    Thanks for asking first. Multi-valued claims now, the hook later: agreed.

    Your three questions

    1. Union, agreed. Skip role names the config doesn't define.
    2. Split only the permissions claim, agreed. A role string stays one name.
    3. Reject at load, agreed. If we combine them later it is AND, and rejecting now keeps that open.

    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"] }] }
    app.EnableOAuth(jwksURL, 3600)
    
    if err := app.EnableRBAC(); err != nil {
        app.Logger().Fatalf("%v", err)
    }
    Case Result
    "roles": ["admin","viewer"] permissions of both roles
    "scope": "orders:read orders:write" both permissions held
    aud is another API 401
    Claim shape we can't read 403 and a log line naming the claim
    Permissions key without audience EnableRBAC returns an error
    Both claim keys set EnableRBAC returns an error

    Does this match what you have in mind?

    Lets cover these

    Gap Ask
    Audience Required in permissions mode, carried in the RBAC config. The token's values are trusted directly, and our check is opt-in today. Same for exp?
    Bad config fails open An ignored EnableRBAC error 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.
    Audit log Roles are PII. Log what granted access; on a deny, only a count. Same for the error handler.
    Silent 403 Log an unreadable claim shape.
    Call order EnableRBAC before EnableOAuth gives 401 on everything. Warn once.
    Performance No merged list per request. My prototype at 10 roles: 380 ns and 0 allocs role by role, 1,880 ns and 11.6 KB merged. Please add a benchmark.

    PR checklist

    • String and array accepted for the same claim; empty and non-string entries ignored; exact match as today.
    • One list of held permissions before the endpoint check.
    • Startup line with mode, claim and audience.
    • Tests through the real OAuth middleware, JSON and YAML, roles[0] unchanged.
    • Docs, config reference, and a small example under examples/.
    • Based on development, not feat(rbac): check rules against registered routes at startup #4390.

    Open questions

    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 EnableBasicAuthWithValidator is the pattern to follow.

  4. akshat-kumar-singhal commented on Oct 5, 2026

    @akshat-kumar-singhal
    ContributorAuthor

    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. exp is required too: a permissions-mode token without exp gets a 401. The OAuth middleware already rejects an expired exp, 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. EnableRBAC returns 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_count and no names, and the role passed 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 as a.b.c as today.

    Key names: permissionsClaimPath and audience (a list).

    File or env: the RBAC config file, so all RBAC settings sit in one place, as the existing roleHeader/jwtClaimPath do.

    rbac_role_extraction_failures: confirmed. It was incremented but never registered, and this PR registers it.

    One addition to flag: when audience is set in roles mode it is checked there too, and setting audience with 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.

  5. akshat-kumar-singhal commented on Oct 16, 2026

    @akshat-kumar-singhal
    ContributorAuthor

    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. See docs/advanced-guide/rbac/page.md.

    The extractor hook continues in #4484, as agreed above ("Multi-valued claims now, the hook later"). Closing this one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions