Roles & Permissions
Tokens tell you who the caller is. What they may do usually lives in your own tables, and with GoTrue or Supabase the token's role claim is authenticated for every user, so it carries no useful authorization signal at all.
ezz has one extension point for this: an authorization resolver.
type AuthorizationResolver interface {
Resolve(ctx context.Context, user auth.User) (auth.Authorization, error)
}
type Authorization struct {
Roles []string
Permissions []string
}
Write a resolver
Query whatever you keep roles and permissions in, and return both lists.
type resolver struct{ db *sql.DB }
func (r resolver) Resolve(ctx context.Context, user auth.User) (auth.Authorization, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT r.name, p.name
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
LEFT JOIN role_permissions rp ON rp.role_id = r.id
LEFT JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = $1`, user.ID())
if err != nil {
return auth.Authorization{}, err // fail closed, becomes a 500
}
defer rows.Close()
var authz auth.Authorization
seen := map[string]bool{}
for rows.Next() {
var role string
var perm sql.NullString
if err := rows.Scan(&role, &perm); err != nil {
return auth.Authorization{}, err
}
if !seen["r:"+role] {
seen["r:"+role] = true
authz.Roles = append(authz.Roles, role)
}
if perm.Valid && !seen["p:"+perm.String] {
seen["p:"+perm.String] = true
authz.Permissions = append(authz.Permissions, perm.String)
}
}
return authz, rows.Err()
}
Attach it to the provider
provider := auth.NewJWTAuthProvider(jwt).WithResolver(resolver{db: db})
app := ezz.New().Auth(provider)
app.GET("/admin", panel).Auth(ezz.Role("admin"))
app.POST("/posts", create).Auth(ezz.Permission("posts:write"))
Nothing else changes. The route rules you already wrote now consult your data.
How resolution behaves
Lazy. The resolver runs the first time something actually asks about a role or a permission. Public routes never call it, and neither do routes using only ezz.Authenticated(). A read-only endpoint that checks nothing costs no query.
Memoized per request. Within one request the result is computed once, no matter how many checks happen, in rules or in your handler.
Authoritative. When a resolver is configured, roles and permissions come only from it. The token's role claim is ignored. Without a resolver, HasRole falls back to the role and roles claims and HasPermission always returns false.
Fail-closed. If the resolver returns an error, the request ends with 500 and the message Authorization failed, not 403. A 403 would tell the caller they lack access, which is not what a failed lookup means. An empty result, on the other hand, is a genuine denial and produces 403.
Fold in roles from the token
If you mint application roles into the token with a custom access-token hook, auth.ClaimRoles pulls them out so you can combine both sources:
func (r resolver) Resolve(ctx context.Context, user auth.User) (auth.Authorization, error) {
authz, err := r.lookup(ctx, user.ID())
if err != nil {
return auth.Authorization{}, err
}
authz.Roles = append(authz.Roles, auth.ClaimRoles(user.GetClaims())...)
return authz, nil
}
ClaimRoles reads the role claim as a string and the roles claim as either []string or []any, skipping empties.
Cache the lookup
A resolver that hits the database on every request that checks a role is a lot of queries. auth.Cached wraps any resolver with a TTL cache keyed by user ID.
authz := auth.Cached(resolver{db: db}, 5*time.Minute)
provider := auth.NewJWTAuthProvider(jwt).WithResolver(authz)
app := ezz.New().Auth(provider)
Successful results are cached for the TTL; errors are never cached, so a database blip does not stick. The cache holds up to 10,000 users and evicts the least recently used.
Hold on to the handle so you can invalidate when access changes:
// after granting or revoking a role
authz.Invalidate(userID)
// after a bulk permissions change
authz.InvalidateAll()
Invalidate in the code path that changes access.If several replicas serve your API, each has its own cache, so Invalidate only clears the process it runs in. Either keep the TTL short or publish invalidations over your message bus and call Invalidate on every instance.
Expand role hierarchies
Hierarchies belong inside the resolver, where you have the full role set. *auth.Hierarchy maps a role to the roles it implies, transitively.
hierarchy := auth.BuildHierarchy().
Role("super_admin").ImpliesAll("admin", "moderator").
Role("admin").Implies("user").
Role("moderator").Implies("user").
Build()
func expand(h *auth.Hierarchy, roles []string) []string {
seen := map[string]bool{}
var out []string
add := func(r string) {
if !seen[r] {
seen[r] = true
out = append(out, r)
}
}
for _, r := range roles {
add(r)
for _, implied := range h.GetImpliedRoles(r) {
add(implied)
}
}
return out
}
func (r resolver) Resolve(ctx context.Context, user auth.User) (auth.Authorization, error) {
authz, err := r.lookup(ctx, user.ID())
if err != nil {
return auth.Authorization{}, err
}
authz.Roles = expand(hierarchy, authz.Roles)
return authz, nil
}
Now ezz.Role("admin") also admits a super_admin, because the resolver returned the expanded set.
Two ready-made hierarchies exist. auth.DefaultWebAppHierarchy() wires super_admin → admin → moderator → user → guest plus owner → admin, manager → user, and editor → viewer. auth.NewCommonRoleHierarchy() is a smaller starting point. Both return a *Hierarchy you can extend with AddRoleImplication, and GetImpliedRoles follows implications transitively while guarding against cycles.
Check roles inside a handler
Rules cover whole routes. For finer decisions, ask the user value:
app.GET("/orders/{id}", ezz.H(func(ctx *ezz.Context, req GetOrderRequest) (*Order, error) {
user := ctx.User().(auth.User)
order, err := store.Find(ctx, req.ID)
if err != nil {
return nil, ezz.NotFound("order not found")
}
if order.CustomerID != user.ID() {
if rc, ok := user.(interface{ HasRole(string) bool }); !ok || !rc.HasRole("support") {
return nil, ezz.NotFound("order not found")
}
}
return order, nil
}))
The resolver runs at most once for the request, so this check is free if a route rule already triggered it.
Because resolution is lazy, a handler-level check can be the first thing to hit the resolver. If it fails, HasRole returns false and the error is available through AuthzError():
if au, ok := ctx.User().(*auth.AuthorizedUser); ok {
if err := au.AuthzError(); err != nil {
return nil, ezz.InternalServerError("authorization unavailable").WithInternal(err)
}
}
Test a resolver
Resolvers are plain functions of a user, so they test without HTTP:
func TestResolverGrantsAdmin(t *testing.T) {
r := resolver{db: testDB(t)}
user := auth.NewBasicUser(&auth.Claims{Subject: "u-1"})
authz, err := r.Resolve(context.Background(), user)
if err != nil {
t.Fatal(err)
}
if !slices.Contains(authz.Roles, "admin") {
t.Fatalf("expected admin, got %v", authz.Roles)
}
}
WithRoleExtractor and WithPermissionChecker on the JWT provider belong to an earlier design and are not consulted during authentication. Role and permission checks go through the resolver, or through the token claims when no resolver is set. The RoleExtractor and PermissionChecker types remain useful as building blocks inside your own resolver.Next steps
- Audit logging to record what was allowed and denied.
- GoTrue proxy if GoTrue issues your tokens.