auth

RBAC

Role and permission checks over the Identity carried in the context: with route guards and service-layer queries.

Import the rbac subpackage to check roles and permissions over the authenticated identity:

import "github.com/gp-system/auth/rbac"

rbac is the auth module's role-and-permission-checks subpackage: role and permission checks over an authenticated Identity carried in the request context. It takes no position on where roles come from: auth.Identify (or anything else) populates the Identity, and rbac only checks: it assumes no database schema and no user table, and it never writes a response.

The package covers three levels: id.HasRole("admin") / id.HasPermission("orders.view") for the simplest boolean checks, rbac.CheckRole(id, "admin") / rbac.CheckPermission(id, "orders.view") which return ErrUnauthenticated/ErrForbidden instead of a bool (useful when you need to distinguish "no identity" from "wrong role" without an extra nil check), and the gpsystem kit's server.RequireRole("admin") / server.RequirePermission("orders.view") for route-level net/http middleware protection that renders those two sentinels as Problems. See Using auth standalone for a hand-written middleware built on CheckRole alone, with no kit involved.

The Identity

The Identity is the caller as authorization decisions and business logic see it: the contract between authentication and authorization.

type Identity struct {
    Subject     string         // the user's identifier (the JWT sub claim)
    Username    string
    Roles       []string
    Permissions []string
    Extra       map[string]any // claim data beyond the standard fields (e.g. tenant)
}

It has two query methods, both with any-of semantics: one match among several arguments is enough.

id.HasRole("admin", "editor")       // true if any of the roles is present
id.HasPermission("orders.view")     // true if the permission is present

Both are safe to call on a nil identity (they report false); this matches the fact that FromContext returns nil when the request did not pass auth middleware. Your guard code therefore never needs a nil check:

if rbac.FromContext(ctx).HasRole("admin") { // works on nil too, reports false
    // ...
}

WithIdentity and FromContext

The Identity travels in a plain context.Context:

ctx = rbac.WithIdentity(ctx, id) // called by the auth middleware
id := rbac.FromContext(ctx)      // *rbac.Identity, or nil without auth

Identity, WithIdentity and FromContext are framework-agnostic: they don't import net/http, or any router. The same contract therefore works outside the HTTP layer too: in services, listeners and jobs, whenever an identity was put into the context.

In the shop, for example, order listing narrows to the caller at the service layer: an admin sees everything, a customer only their own.

// internal/modules/shop/surfaces/api/service/orders.go
func (s *Service) ListOrders(ctx context.Context, cursor string) ([]Order, error) {
    id := rbac.FromContext(ctx)
    if id.HasRole("admin") {
        return s.orders.ListAll(ctx, cursor)
    }
    return s.orders.ListByUser(ctx, id.Subject, cursor)
}

This is data scoping (what to show), not an access decision (whether it is allowed at all). Per-request authorization, meaning "may they view this particular order", is the job of policies.

Route guards

The kit's server.RequireRole / server.RequirePermission middlewares protect a whole route group. They assume server.AuthMiddleware already ran before them; they only inspect the Identity in the context, via rbac.CheckRole/CheckPermission, and render the result as a Problem.

router.Route("/admin/shop", func(r chi.Router) {
    r.Use(
        server.AuthMiddleware(deps.Auth), // Bearer → parse → Identity into context
        server.RequireRole("admin"),      // 401 without identity, 403 without the role
    )
    // ...
})

// permission-based variant, with any-of semantics:
router.Route("/reports", func(r chi.Router) {
    r.Use(server.AuthMiddleware(deps.Auth),
        server.RequirePermission("reports.view", "reports.export"))
    // ...
})

The responses are RFC 9457 problem documents:

SituationResult
no Identity in the context (auth did not run)401 authentication required
identity present, but none of the listed roles/permissions held403 insufficient privileges
any of the listed roles/permissions held (any-of)pass

Underneath, server.RequireRole/RequirePermission call rbac.CheckRole/CheckPermission directly and map rbac.ErrUnauthenticated/ErrForbidden to those two statuses; a caller with no kit does the same mapping by hand, as shown in Using auth standalone.

With the kit

In the shop, the entire admin surface (product CRUD, image upload) sits behind the pair above: the middleware goes into the RegisterAdmin function generated by add surface, so the protection is surface-wide and the sibling api surface is untouched. The exact wiring (and adding the deps.Auth field) is shown on the authentication page; the surface-per-function structure is covered by add surface.

The same Identity also shows up outside the HTTP request/response cycle: the realtime gateway resolves each WebSocket connection's JWT into an Identity and checks it against the Allow/AllowPrefix callbacks you register for a topic, so a connection only ever subscribes to channels the caller is authorized for.

For finer granularity the same guards can go on a smaller group instead of the whole surface, but once the decision is no longer "do they have this role" but "do they own this resource", it is not RBAC anymore: that is what the policy layer is for.

Where roles and permissions come from

In the kit's default setup, from the JWT claims: the login endpoint writes the user's roles and permissions into DefaultClaims, and the auth middleware fills the Identity fields from them. The full chain is on the authentication page. Since rbac only ever sees the Identity, the source is swappable: a custom claims type (Identify/server.AuthMiddlewareFor), an identity built from an API key, or a test fixture all work the same way.

Tests need no HTTP: with rbac.WithIdentity you can put the desired identity directly into the context, and the service layer behaves as if the middleware had populated it.
ctx := rbac.WithIdentity(t.Context(), &rbac.Identity{
    Subject: "u-42", Roles: []string{"admin"},
})

A role says what the caller may do in general. When the decision also needs the concrete request (are they the owner, are they in the same tenant, is the record in the right state), move on to policies.

Patterns used

  • Context-injected identity (WithIdentity/FromContext, unexported key, nil-safe methods): Design patterns.
  • Higher-order authorization guard (requireFn, shared by both RequireRole and RequirePermission): Design patterns.
Copyright © 2026