← назад к разделу

Когда 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.