RBAC
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"))
router.Route("/admin/shop", func(r chi.Router) {
r.Use(
auth.MiddlewareHTTP(deps.Auth), // Bearer → parse → Identity into context
rbac.RequireRoleHTTP("admin"), // 401 without identity, 403 without the role
)
// ...
})
// permission-based variant, with any-of semantics:
router.Route("/reports", func(r chi.Router) {
r.Use(auth.MiddlewareHTTP(deps.Auth),
rbac.RequirePermissionHTTP("reports.view", "reports.export"))
// ...
})
The semantics are identical on both engines, and the responses are RFC 9457 problem documents:
| Situation | Result |
|---|---|
no Identity in the context (auth did not run) | 401 authentication required |
| identity present, but none of the listed roles/permissions held | 403 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.
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 bothRequireRoleandRequirePermission): Design patterns.