📖 2 min read (~ 400 words).

Custom Authorizer (RBAC)

Authentication answers who. Authorization answers may they do this? — a separate decision the runtime asks of your Authorizer after the principal has been resolved (core / interfaces).

The runtime ships one trivial authorizer (security.Authorized() — always-allow). Anything more interesting you write yourself.

A role-based authorizer

type principal struct {
	Subject string
	Roles   []string
}

// roleACL: which roles may call which "method path".
var roleACL = map[string]map[string]bool{
	"GET /pets":         {"reader": true, "admin": true},
	"POST /pets":        {"writer": true, "admin": true},
	"DELETE /pets/{id}": {"admin": true},
}

func RBACAuthorizer() runtime.Authorizer {
	return runtime.AuthorizerFunc(func(r *http.Request, p any) error {
		route := middleware.MatchedRouteFrom(r)
		key := fmt.Sprintf("%s %s", r.Method, route.PathPattern)

		allowed, ok := roleACL[key]
		if !ok {
			return errors.New(http.StatusForbidden, "no ACL entry for %s", key)
		}

		prin, ok := p.(*principal)
		if !ok {
			return errors.New(http.StatusForbidden, "principal type mismatch")
		}

		for _, role := range prin.Roles {
			if allowed[role] {
				return nil // 👍
			}
		}

		return errors.New(http.StatusForbidden, "role %v cannot %s", prin.Roles, key)
	})
}

Full source: docs/examples/auth/customauthorizer/main.go

Two things worth knowing about the return value:

  • A return implementing errors.Error is propagated as-is (status code preserved).
  • Any other error is wrapped as errors.New(403, err.Error()).

That’s why the example uses errors.New(http.StatusForbidden, …) rather than fmt.Errorf — to keep control of the status code.

Wire it

api := untyped.NewAPI(doc).WithJSONDefaults()

api.RegisterAuth("bearer", security.BearerAuth("bearer", verifyBearer))
api.RegisterAuthorizer(RBACAuthorizer())

Full source: docs/examples/auth/customauthorizer/main.go

That’s it — the runtime calls Authorize on every authenticated request after the authenticator has populated the principal.

Reading the principal & scopes elsewhere

Inside extra middleware mounted via middleware.Builder, or from a custom error handler:

principal := middleware.SecurityPrincipalFrom(r) // any
scopes := middleware.SecurityScopesFrom(r)       // []string
route := middleware.MatchedRouteFrom(r)          // *MatchedRoute

Full source: docs/examples/auth/customauthorizer/main.go

Useful for audit logging, per-tenant rate limiting, or surfacing a “why was this denied?” message in error responses.

Variations

  • OPA / Casbin / your own engine: same shape — call out to the policy evaluator from inside the AuthorizerFunc.
  • Skip authorization for some routes: combine the ACL with a short-circuit on the matched route (route.Operation.ID, route.PathPattern, etc.) before consulting the engine.
  • Per-method body inspection: Authorizer runs after authentication but before parameter binding, so the request body has not been consumed at this point — for body-based decisions (“the document the user is editing must belong to them”), do the check inside the operation handler, where the bound params are available.