Security

RBAC

Role and permission checks over the Identity carried in the context: with route guards and service-layer queries.
import "github.com/gp-system/gpsystem/rbac"

The rbac package provides role and permission checks over an authenticated Identity carried in the request context. It deliberately takes no position on where roles come from: the auth middleware (or anything else) populates the Identity, and rbac only checks: it assumes no database schema and no user table.

The package covers two levels: id.HasRole("admin") / id.HasPermission("orders.view") for service-layer checks called directly from code, and rbac.RequireRole("admin") / rbac.RequirePermission("orders.view") for route-level middleware protection.

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 deliberately 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 import neither Fiber nor net/http. 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 RequireRole / RequirePermission middlewares protect a whole route group. They assume the auth middleware already ran before them; they only inspect the Identity in the context.

adminGroup := router.Group("/admin/shop")
adminGroup.Use(
    auth.Middleware(deps.Auth),      // Bearer → parse → Identity into context
    rbac.RequireRole("admin"),       // 401 without identity, 403 without the role
)

// permission-based variant, with any-of semantics:
reports := router.Group("/reports", auth.Middleware(deps.Auth),
    rbac.RequirePermission("reports.view", "reports.export"))

The semantics are identical on both engines, and 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

The shop's admin surface

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.

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 (MiddlewareFor), 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