Опирается на правила:
AUTH-7,AUTH-8,AUTH-9из Auth Patterns — раздел 3. RBAC: маппинг ролей.
Важно знать
- Роли в JWT —
realm_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 без RequireRoles | AUTH-9 | RequireRoles на каждой группе, публичных роутов нет |
Сравнение строк вне middleware: p.Roles[0] == "admin" | AUTH-9 | RequireRoles(RoleAdmin) в middleware |
| Строковые литералы ролей россыпью по коду | AUTH-9 | константы RoleCustomer, RoleAdmin и т.д. |
Новая роль для feature flag (customer-premium) | AUTH-8 | атрибут на агрегате, проверка в Handler |
| 10+ ролей в каталоге | AUTH-8 | customer/seller/admin/system + ABAC для resource-level |
RequireRoles на каждом отдельном роуте, не на группе | AUTH-9 | один middleware на chi.Group |
RBAC для resource-level: проверка order.CustomerID в middleware | AUTH-3 | RBAC endpoint + ABAC в Handler/AccessPolicy |
Raw-токен в context.Context вместо *Principal | AUTH-4 | JWTValidator.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.