Когда HTTP-запрос приходит на сервер, он уже прошёл аутентификацию — JWT-токен проверен и валиден. Но одной проверки подписи недостаточно: нужно убедиться, что у этого пользователя есть право вызвать именно этот endpoint. За это отвечает RBAC — Role-Based Access Control, авторизация на основе ролей.
В Go-стеке с chi-роутером это делается двумя функциями: extractRoles читает роли из токена, а RequireRoles проверяет их в middleware до того, как запрос доходит до обработчика.
Как роли попадают из токена в приложение
JWT-токен несёт роли в разных местах в зависимости от сервера авторизации. Keycloak кладёт их в realm_access.roles:
{
"sub": "customer-77",
"realm_access": {
"roles": ["customer"]
}
}
Стандартный OAuth2 использует поле scope — строку через пробел: "scope": "customer read:orders".
Функция extractRoles обрабатывает оба формата и возвращает роли как срез строк:
// adapters/in/http/security/extract.go
func extractRoles(claims jwt.MapClaims) []string {
if ra, ok := claims["realm_access"].(map[string]any); ok {
if roles, ok := ra["roles"].([]any); ok {
return toStrings(roles)
}
}
if scope, ok := claims["scope"].(string); ok {
return strings.Fields(scope)
}
return nil
}
extractRoles вызывается внутри JWTValidator.Validate — один раз при разборе токена. После этого нигде в коде не нужно обращаться к сырым claims: в context.Context лежит готовый *Principal с полем Roles.
Каталог ролей
Ролей в системе должно быть мало. Практика показывает, что почти все задачи покрываются четырьмя стандартными ролями:
| Роль | Кто | Что делает |
|---|---|---|
customer | Конечный пользователь | Создаёт и читает свои заказы, оплачивает |
seller | Продавец | Управляет своими товарами, видит заказы своих товаров |
admin | Внутренний оператор | Полный доступ, каждое действие пишется в журнал |
system | Другой сервис | Межсервисные вызовы через Client Credentials или mTLS |
Если хочется добавить customer-premium или partner-admin — скорее всего это не новая роль, а атрибут существующей. Примеры:
- «Покупателям с подпиской открыты другие возможности» — это атрибут
Customer.HasSubscription, который проверяется в обработчике, а не рольcustomer-premium. - «B2B-клиенты не видят розничный каталог» — это разные части системы (разные Bounded Context), а не разные роли в одном сервисе.
- «Аналитик может только смотреть» — либо атрибут на роли
systemс ограниченным доступом, либо отдельная роль, если это отдельный класс пользователей.
Небольшой каталог ролей — это осознанное ограничение, которое упрощает систему авторизации и упрощает её аудит.
RequireRoles: middleware на группу роутов
Роли объявляются как константы, чтобы не разбрасывать строковые литералы по всему коду:
// adapters/in/http/middleware/rbac.go
const (
RoleCustomer = "customer"
RoleSeller = "seller"
RoleAdmin = "admin"
RoleSystem = "system"
)
Сам middleware получает список допустимых ролей и возвращает стандартный http.Handler:
func RequireRoles(roles ...string) func(http.Handler) http.Handler {
allowed := make(map[string]struct{}, len(roles))
for _, r := range roles {
allowed[r] = struct{}{}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if p == nil {
httperr.Write(w, r, &apperr.AuthError{Reason: "unauthenticated"})
return
}
for _, role := range p.Roles {
if _, ok := allowed[role]; ok {
next.ServeHTTP(w, r)
return
}
}
httperr.Write(w, r, &apperr.ForbiddenError{Required: roles})
})
}
}
Если *Principal отсутствует в контексте — значит запрос не прошёл аутентификацию, возвращается 401. Если principal есть, но ни одна роль не совпала — 403. Достаточно одной совпавшей роли из списка.
Как настроить роутер
RequireRoles ставится на chi.Group, не на каждый отдельный роут. Так один middleware защищает всю группу сразу:
// adapters/in/http/router.go
r := chi.NewRouter()
r.Use(middleware.AuthN(jwtValidator)) // кладёт *Principal в контекст
r.Group(func(r chi.Router) {
r.Use(middleware.RequireRoles(RoleCustomer))
r.Post("/orders", h.CreateOrder)
r.Get("/orders/{id}", h.GetOrder)
r.Post("/orders/{id}/cancel", h.CancelOrder)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireRoles(RoleSeller))
r.Get("/products", h.ListProducts)
r.Post("/products", h.CreateProduct)
r.Put("/products/{id}", h.UpdateProduct)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireRoles(RoleAdmin))
r.Post("/orders/{id}/refund", h.AdminRefundOrder)
r.Post("/customers/{id}/block", h.AdminBlockCustomer)
})
AuthN стоит первым на всём роутере — к моменту, когда RequireRoles запустится, *Principal уже лежит в контексте.
Если endpoint нужен нескольким ролям — передаём несколько аргументов:
r.Group(func(r chi.Router) {
r.Use(middleware.RequireRoles(RoleCustomer, RoleAdmin))
r.Get("/orders/{id}", h.GetOrder)
})
RBAC и ABAC: два разных вопроса
RBAC отвечает на вопрос: «может ли роль customer вообще вызвать GET /orders/{id}?» Это проверка на уровне endpoint.
Но есть другой вопрос: «может ли именно этот покупатель читать именно этот заказ?» Роль здесь не поможет — нужно загрузить заказ и проверить, что order.CustomerID совпадает с principal.Sub. Это называется ABAC (Attribute-Based Access Control) и живёт в обработчике, а не в middleware:
// core/order/handler/get_order.go
func (h *GetOrderHandler) Handle(ctx context.Context, cmd GetOrderCommand) (OrderView, error) {
order, err := h.repo.ByID(ctx, cmd.OrderID)
if err != nil {
return OrderView{}, fmt.Errorf("load order %s: %w", cmd.OrderID, err)
}
principal := security.PrincipalFrom(ctx)
if err := h.policy.CheckOwnership(order, principal); err != nil {
return OrderView{}, err
}
return toView(order), nil
}
Без RBAC-проверки в middleware любой покупатель может попробовать обратиться к чужому заказу. Без ABAC-проверки в обработчике — любой покупатель читает чужие заказы, просто угадав ID. Оба слоя нужны.
Частые ошибки
Сравнение строк вне middleware. Писать if p.Roles[0] == "admin" внутри обработчика — плохая практика: логика авторизации расползается по коду и её сложно найти при аудите. Роль проверяется только через RequireRoles.
Строковые литералы вместо констант. RequireRoles("admin") в пяти местах — это пять мест, где можно опечататься. Константы RoleAdmin, RoleCustomer и т.д. решают эту проблему.
Группа без RequireRoles. Если chi.Group не имеет RequireRoles, все входящие запросы проходят к обработчикам без проверки роли. Если endpoint действительно публичный — это отдельное осознанное решение, которое нужно явно отметить в коде.
RequireRoles на каждом роуте вместо группы. Работает, но дублирует код и делает router сложнее. Группировка роутов по ролям — стандартный способ.
Коротко
- JWT-токен несёт роли в
realm_access.roles(Keycloak) илиscope(стандартный OAuth2);extractRolesобрабатывает оба формата. - После разбора токена роли лежат в
Principal.Roles; сырые claims нигде не используются. - Стандартный каталог:
customer,seller,admin,system. Новая роль — сигнал пересмотреть доменную модель. RequireRolesставится наchi.Group, не на каждый отдельный роут.- RBAC отвечает за право вызвать endpoint; проверку «чей ресурс» делает ABAC в обработчике.
- Роли объявляются константами — никаких строковых литералов россыпью по коду.
Что почитать дальше
- JWT validation в Go — как
*Principalпопадает в контекст до RBAC. - ABAC: владение ресурсом —
AccessPolicyиCheckOwnershipпосле RBAC. - Где какая проверка — где живёт
AuthN, гдеRequireRoles, где ABAC. - Межсервисные вызовы — роль
system: Client Credentials Flow и mTLS.