Skip to content

Roles and Scopes

wiki edited this page Sep 4, 2026 · 1 revision

Roles and scopes

Both work the same way: the route declares what is required per scheme, and the scheme's validator checks it after authentication succeeds.

func (r *AdminRoute) RequiredRoles() map[string][]string {
	return map[string][]string{"jwt": {"admin", "user:write"}} // ALL of them
}

func (r *AdminRoute) RequiredScopes() map[string][]string {
	return map[string][]string{"jwt": {"users.write"}}
}

Roles are an application's own authorization model. Scopes are OAuth2 / OIDC delegation. Use whichever your tokens carry — or both.

A missing role or scope is a 403, not a 401: the caller authenticated, they are just not permitted.

Implementing enforcement

Two interfaces, and you need both halves:

type RoleValidator interface {
	ValidateRoles(r *http.Request, principal interface{}, requiredRoles []string) error
}

type RoleEnforcer interface {
	RoleValidator
	SupportsRoles() bool
}

ScopeValidator / ScopeEnforcer are identical in shape.

Implement them on the validator — the thing that actually understands the token — and BearerScheme delegates to it:

type jwtValidator struct{ keys jwk.Set }

func (v *jwtValidator) ValidateToken(token string) (interface{}, error) { … }

func (v *jwtValidator) SupportsRoles() bool { return true }

func (v *jwtValidator) ValidateRoles(r *http.Request, principal interface{}, required []string) error {
	claims, ok := principal.(*Claims)
	if !ok {
		return errors.New("unexpected principal type")
	}
	for _, want := range required {
		if !slices.Contains(claims.Roles, want) {
			return fmt.Errorf("missing role %q", want) // logged, never sent
		}
	}
	return nil
}

Why SupportsRoles exists

This is the most important thing on this page, because the failure it prevents is invisible.

BearerScheme implements RoleValidator. Its implementation delegates to its inner TokenValidator when that validator also implements RoleValidator — and used to return nil when it did not:

func (s *BearerScheme) ValidateRoles(...) error {
	if rv, ok := s.validate.(RoleValidator); ok {
		return rv.ValidateRoles(...)
	}
	return nil  // ← "no validator" indistinguishable from "allowed"
}

So scheme.(RoleValidator) succeeds, the middleware calls ValidateRoles, gets nil, and lets the request through. A route declaring RequiredRoles: {"jwt": {"admin"}} against a bearer scheme whose validator knows nothing about roles was served to every authenticated caller — and nothing anywhere reported it.

Reflection cannot see this either: the fail-open is one level down, inside a field.

SupportsRoles moves the answer to where it is knowable. Two consequences:

  • At startup, the framework refuses to boot when a route declares roles for a scheme reporting false. See Startup Validation.
  • At request time, an unenforceable requirement is a 403, never a pass.

This deliberately reverses an earlier design that described silent skipping as backward compatibility. It was: authorization silently granting access nobody granted.

Documenting the claim

security.NewBearerScheme("jwt", v).SetRolesClaim("realm_access.roles")

Documentation only. It becomes x-roles-claim on the OpenAPI security scheme so UI tooling can surface where roles live. It configures no enforcement — that is entirely your validator's business.

ALL, not ANY

Every listed role and every listed scope must be present. There is no built-in "any of" — if you need it, express it in the validator:

func (v *jwtValidator) ValidateRoles(r *http.Request, p interface{}, required []string) error {
	// Convention: "a|b" means either.
	for _, spec := range required {
		if !anyOf(p, strings.Split(spec, "|")) {
			return fmt.Errorf("missing any of %s", spec)
		}
	}
	return nil
}

Keep such a convention documented next to the routes that use it — a reader seeing {"admin|owner"} should not have to guess.

Roles per scheme, not per route

The map is keyed by scheme name because a multi-scheme route has more than one identity, and they carry different authorization:

func (r *InternalRoute) RequiredSchemes() []string {
	return []string{"serviceToken", "session"}
}

func (r *InternalRoute) RequiredRoles() map[string][]string {
	return map[string][]string{
		"serviceToken": {"svc:orders"},
		"session":      {"support"},
	}
}

Both must pass. A key naming a scheme not in RequiredSchemes is a startup error, not a silently ignored entry.

Clone this wiki locally