Опирается на правила: AUTH-7, AUTH-8, AUTH-9 из Auth Patterns — раздел 3. RBAC: маппинг ролей.

Важно знать

  • Роли в JWTrealm_access.roles (Keycloak) или scope (стандартный OAuth2); оба случая покрывает extractRoles.
  • Principal.Roles — единственный источник ролей во всём стеке; raw-токен в context.Context не кладётся.
  • RequireRoles ставится на chi.Group, не на каждый отдельный роут — один middleware на ролевую группу.
  • Каталог ролей UCP: customer, seller, admin, system. Любая новая роль — пересмотр Bounded Context.
  • Каждая chi.Group обязана иметь RequireRoles; группа без него — критическое нарушение AUTH-9.
  • Сравнение строк (p.Roles[0] == "admin") вне middleware — запрещено; роль без константы — тоже.
  • RBAC отвечает endpoint-level: может ли эта роль вообще вызвать endpoint. «Этот ли его order» — это ABAC.

RBAC (Role-Based Access Control) — первый слой авторизации после JWT validation. В Go-стеке он реализуется двумя функциями: extractRoles вычитывает роли из claims и кладёт их в Principal, RequireRoles проверяет их в chi-middleware до того, как запрос достигает handler-функции. UCP формулирует минимальный каталог ролей: избыток ролей означает либо размытую доменную модель, либо ABAC, замаскированный под RBAC.

Извлечение ролей: extractRoles

AUTH-7: роли маппятся из claim'ов JWT в Principal.Roles при валидации токена.

// 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
}

func toStrings(in []any) []string {
    out := make([]string, 0, len(in))
    for _, v := range in {
        if s, ok := v.(string); ok {
            out = append(out, s)
        }
    }
    return out
}

Keycloak кладёт роли в realm_access.roles; стандартный OAuth2 — в scope через пробел. extractRoles обрабатывает оба случая и вызывается внутри JWTValidator.Validate, поэтому нигде за пределами security/ работать с raw claims не нужно.

Типичный Keycloak JWT для пользователя-покупателя:

{
  "sub": "customer-77",
  "realm_access": {
    "roles": ["customer"]
  }
}

После Validate в context.Context доступен *Principal{Sub: "customer-77", Roles: []string{"customer"}}.

Каталог ролей UCP

AUTH-8: четыре стандартных роли.

РольКтоЧто делает
customerКонечный пользовательСоздаёт и читает свои заказы, оплачивает
sellerПродавец на маркетплейсеУправляет своими товарами, видит заказы своих товаров
adminВнутренний операторПолный доступ + обязательный audit log
systemСервис-к-сервисуВызовы Client Credentials или mTLS

Любая новая роль — повод пересмотреть Bounded Context. Если появляется customer-premium или partner-admin, это почти всегда атрибут на существующей роли или отдельный агрегат, а не новая роль. Примеры:

  • «Покупателям с подпиской открыты другие endpoints» — это атрибут Customer.HasSubscription, проверяемый в Handler, а не роль customer-premium.
  • «B2B-клиенты не видят розничный каталог» — разные Bounded Context, разные сервисы, не роль.
  • «Аналитик может только смотреть» — permission-система или отдельная роль system с read-scope, но не viewer-admin.

Минимизация каталога ролей — дисциплина, а не ограничение.

RequireRoles: middleware на chi.Group

AUTH-9: каждая группа роутов имеет RequireRoles. Endpoint без проверки роли — критическое нарушение.

// adapters/in/http/middleware/rbac.go

const principalKey ctxKey = "principal"

const (
    RoleCustomer = "customer"
    RoleSeller   = "seller"
    RoleAdmin    = "admin"
    RoleSystem   = "system"
)

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})
        })
    }
}

func PrincipalFrom(ctx context.Context) *security.Principal {
    p, _ := ctx.Value(principalKey).(*security.Principal)
    return p
}

ForbiddenError рендерится edge-рендерером в 403; AuthError (когда principal отсутствует) — в 401. Эта граница соответствует AUTH-6: аутентификация и авторизация — разные коды ответа.

Роутер: группировка по ролям

// adapters/in/http/router.go

r := chi.NewRouter()
r.Use(middleware.AuthN(jwtValidator))

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 стоит первым на всём роутере — кладёт *Principal в context.Context. RequireRoles стоит первым внутри группы — handler-функции не видят запросов без подходящей роли.

Если endpoint доступен нескольким ролям — передаём несколько аргументов:

r.Group(func(r chi.Router) {
    r.Use(middleware.RequireRoles(RoleCustomer, RoleAdmin))
    r.Get("/orders/{id}", h.GetOrder)
})

RequireRoles проверяет пересечение: достаточно одной совпавшей роли.

RBAC ≠ ABAC

RBAC отвечает endpoint-level: может ли роль customer вызвать GET /orders/{id}.

RBAC не отвечает: может ли customer-77 читать order-42. Для этого нужно загрузить Order, проверить order.CustomerID == principal.Sub — это ABAC, и он живёт в Handler или AccessPolicy, не в middleware.

// adapters/in/http/router.go — RBAC: роль может вызвать endpoint
r.Group(func(r chi.Router) {
    r.Use(middleware.RequireRoles(RoleCustomer, RoleAdmin))
    r.Get("/orders/{id}", h.GetOrder)
})

// core/order/handler/get_order.go — ABAC: этот ли его order
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 пускает любого customer читать чужие заказы; только ABAC без RBAC — значит RequireRoles не стоит.

Что запрещено

АнтипаттернПравилоЧто взамен
chi.Group без RequireRolesAUTH-9RequireRoles на каждой группе, публичных роутов нет
Сравнение строк вне middleware: p.Roles[0] == "admin"AUTH-9RequireRoles(RoleAdmin) в middleware
Строковые литералы ролей россыпью по кодуAUTH-9константы RoleCustomer, RoleAdmin и т.д.
Новая роль для feature flag (customer-premium)AUTH-8атрибут на агрегате, проверка в Handler
10+ ролей в каталогеAUTH-8customer/seller/admin/system + ABAC для resource-level
RequireRoles на каждом отдельном роуте, не на группеAUTH-9один middleware на chi.Group
RBAC для resource-level: проверка order.CustomerID в middlewareAUTH-3RBAC endpoint + ABAC в Handler/AccessPolicy
Raw-токен в context.Context вместо *PrincipalAUTH-4JWTValidator.Validate*Principal в контекст

Куда дальше

  • JWT validation — JWTValidator с JWKS-keyfunc: как *Principal попадает в контекст до RBAC.
  • ABAC: владение ресурсом — AccessPolicy и CheckOwnership после RBAC.
  • Где какая проверка — edge vs BFF vs Domain: где живёт AuthN, где RequireRoles, где ABAC.
  • Audit admin-команд — роль admin обходит ABAC, но каждое действие пишется в *_audit_log.
  • Service-to-service — роль system: Client Credentials Flow и mTLS в adapters/out/*.
  • PII и секреты — что нельзя класть в error.Error() и slog-атрибуты.
  • Идемпотентность — RequireIdempotencyKey на money-командах рядом с RequireRoles.
  • Хранение токенов на клиенте — BFF: HttpOnly cookie вместо localStorage.