Auth

Auth Rules

Require authentication, roles, permissions, or your own predicate, per route or per group.

A rule is a small interface. The app calls Check with the user your provider returned; nil means allow.

type AuthRule interface {
    Check(user any) error
    IsPublic() bool
}

The built-in rules

RuleRequires
ezz.Public()Nothing. Authenticate is not even called
ezz.Authenticated()A non-nil user, so any valid token
ezz.Role("admin")HasRole("admin") returns true
ezz.AnyRole("admin", "mod")At least one of the roles
ezz.AllRoles("billing", "admin")Every listed role
ezz.Permission("posts:write")HasPermission("posts:write") returns true
ezz.Custom(fn)fn(user) returns true
app.GET("/health", health).Public()
app.GET("/me", profile).Auth(ezz.Authenticated())
app.GET("/admin", panel).Auth(ezz.Role("admin"))
app.GET("/reports", reports).Auth(ezz.AnyRole("admin", "analyst"))
app.POST("/invoices", create).Auth(ezz.AllRoles("billing", "admin"))
app.DELETE("/posts/{id}", remove).Auth(ezz.Permission("posts:delete"))

Know what the rules check against

Role and permission rules use interface assertions on the user value:

type RoleChecker interface {
    HasRole(role string) bool
}

type PermissionChecker interface {
    HasPermission(permission string) bool
}

If the user does not implement the interface the rule needs, the request is denied with 403. The *auth.AuthorizedUser returned by the JWT provider implements both, so this only bites when you write a custom provider that returns your own type. Add the two methods and every rule works.

Where the roles come from depends on your setup:

  • With an authorization resolver, roles and permissions come from your store, and the token's role claim is ignored.
  • Without one, HasRole reads the role (string) and roles (array) claims from the token, and HasPermission always returns false, since a token carries no permission set.

Set rules at three levels

Precedence runs from broad to narrow.

app := ezz.New().Auth(provider)              // 1. app default: Authenticated

api := app.Group("/api").Auth(ezz.Role("member"))   // 2. group rule
api.GET("/feed", feed)                               // member
api.GET("/admin", adminPanel).Auth(ezz.Role("admin")) // 3. route rule wins
api.GET("/status", status).Public()                   // route rule wins

A nested group inherits the parent's rule at the moment it is created:

api := app.Group("/api").Auth(ezz.Role("member"))
admin := api.Group("/admin").Auth(ezz.Role("admin"))
Set a group's rule before registering routes on it or deriving children from it. Routes copy the rule when they are registered, and children copy it when they are created.

Write a custom rule

ezz.Custom covers predicates over the user value:

app.GET("/beta", betaFeature).Auth(ezz.Custom(func(user any) bool {
    u, ok := user.(auth.User)
    if !ok {
        return false
    }
    flag, _ := u.GetAttribute("beta_enabled")
    enabled, _ := flag.(bool)
    return enabled
}))

For anything with its own status code or message, implement the interface directly:

type tenantRule struct{ tenant string }

func (r tenantRule) Check(user any) error {
    u, ok := user.(*Account)
    if !ok {
        return ezz.ErrForbidden
    }
    if u.Tenant != r.tenant {
        return ezz.NewHTTPError(http.StatusNotFound, "not found")  // hide existence
    }
    if u.Suspended {
        return ezz.NewHTTPError(http.StatusPaymentRequired, "account suspended")
    }
    return nil
}

func (tenantRule) IsPublic() bool { return false }

func Tenant(name string) ezz.AuthRule { return tenantRule{tenant: name} }

An *ezz.HTTPError returned from Check keeps its status and message. Any other error becomes a plain 403.

A rule only sees the user value. It cannot read the request, so anything path-dependent, such as "the caller owns this document", belongs in the handler where you have the request and your data layer.

Check ownership in the handler

app.GET("/documents/{id}", ezz.H(func(ctx *ezz.Context, req GetDocRequest) (*Document, error) {
    user := ctx.User().(auth.User)

    doc, err := store.Find(ctx, req.ID)
    if err != nil {
        return nil, ezz.NotFound("document not found")
    }
    if doc.OwnerID != user.ID() {
        return nil, ezz.NotFound("document not found")   // don't confirm it exists
    }
    return doc, nil
}))

What the caller sees

OutcomeResponse
No or invalid credentials401 with {"error":true,"message":"Unauthorized","status":401}
Rule denied the user403 with {"error":true,"message":"Forbidden","status":403}
Rule returned an *ezz.HTTPErrorThat status and message
Authorization resolver failed500 with {"error":true,"message":"Authorization failed","status":500}

Next steps

Copyright © 2026