Опирается на правила:
AUTH-1,AUTH-2,AUTH-3из Auth Patterns Rules → раздел 1. Где какая проверка делается.
Важно знать
- Gateway / API edge — аутентификация:
AuthN-middleware валидирует JWT и кладёт*Principalвcontext.Context.- BFF / Application Layer — грубая авторизация по роли (RBAC):
RequireRoles("admin")на chi-группе роутов.- Domain Service / Handler — авторизация по ресурсу (ABAC):
order.CustomerID == principal.SubвAccessPolicy.- ABAC никогда на Gateway — Gateway не знает доменную модель.
- Каждый слой — одна ответственность:
AuthNне делает RBAC, Handler не делает JWT-валидацию.RequireRolesбезAuthNперед ним не работает:PrincipalFrom(ctx)вернётnil.- Размытие ответственности — главная причина дыр в авторизации.
- Ошибки — значения:
&AuthError{}→ 401,&ForbiddenError{}→ 403; путать запрещено (AUTH-6).
Auth — это не один монолитный шаг, а три разных проверки на трёх разных уровнях. Каждый уровень имеет своё знание и свою задачу. Смешение приводит либо к дублированию с шансом расхождения, либо к пропускам — endpoint без правильного слоя становится backdoor-ом.
Три уровня и три ответственности
| Уровень | Что проверяет | Инструмент (Go) |
|---|---|---|
| Gateway / API edge | подпись JWT, exp, iss, aud, rate limit | AuthN-middleware + golang-jwt/jwt/v5 + JWKS-keyfunc |
| BFF / Application Layer | RBAC: есть ли роль для этого endpoint | RequireRoles("customer") на chi.Group |
| Domain Service / Handler | ABAC: владеет ли этот user этим ресурсом | AccessPolicy.CheckOwnership в core/<aggregate>/ |
Gateway — аутентификация (AUTH-1)
AuthN-middleware стоит первым в цепочке на всём роутере. Он отвечает на вопрос «кто этот клиент»: извлекает Authorization: Bearer <token>, вызывает JWTValidator.Validate, при успехе кладёт *Principal в контекст.
// adapters/in/http/middleware/authn.go
func AuthN(v *security.JWTValidator) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if raw == "" {
httperr.Write(w, r, &apperr.AuthError{Reason: "missing token"})
return
}
principal, err := v.Validate(raw)
if err != nil {
httperr.Write(w, r, err) // AUTH-6: невалидный JWT → 401
return
}
ctx := context.WithValue(r.Context(), principalKey, principal)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
JWTValidator использует golang-jwt/jwt/v5 + keyfunc/v3 (JWKS-кеш с IdP). Подробнее — JWT validation.
Что Gateway не делает: не знает endpoint paths, не знает доменную модель, не проверяет владение ресурсом. При невалидном JWT downstream сервисы не запрашиваются.
BFF — грубая авторизация по роли (AUTH-2)
RequireRoles ставится на chi.Group и отвечает на вопрос «может ли этот клиент вообще обратиться к этому endpoint».
// adapters/in/http/router.go
r := chi.NewRouter()
r.Use(middleware.AuthN(jwtValidator)) // AUTH-1: первым, на всём роутере
r.Group(func(r chi.Router) {
r.Use(middleware.RequireRoles("customer")) // AUTH-2: только customer
r.Post("/orders", h.CreateOrder)
r.Get("/orders/{id}", h.GetOrder)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireRoles("admin")) // AUTH-2: только admin
r.Post("/orders/{id}/cancel", h.AdminCancelOrder)
r.Post("/products", h.CreateProduct)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireRoles("customer", "admin"))
r.Get("/products/{id}", h.GetProduct)
})
// adapters/in/http/middleware/rbac.go
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})
})
}
}
RBAC отвечает на endpoint-level вопросы: POST /orders — только customer, POST /admin/* — только admin. Если роль не подходит — 403 Forbidden до входа в Handler. Что BFF не делает: не проверяет, чей конкретно агрегат. Этим занимается следующий слой.
Domain Handler — авторизация по ресурсу (AUTH-3)
AccessPolicy живёт в core/<aggregate>/ и отвечает на вопрос «может ли этот клиент работать с этим конкретным ресурсом».
// core/order/access.go
type OrderAccessPolicy struct{}
func (p *OrderAccessPolicy) CheckOwnership(order *Order, principal *security.Principal) error {
for _, role := range principal.Roles {
if role == "admin" {
return nil // AUTH-12: admin проходит, audit пишет Handler
}
}
if order.CustomerID != principal.Sub {
return &apperr.ForbiddenError{
Resource: "order",
ResourceID: order.ID,
}
}
return nil
}
// 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 := middleware.PrincipalFrom(ctx)
if err := h.policy.CheckOwnership(order, principal); err != nil { // AUTH-10
return OrderView{}, err
}
return toView(order), nil
}
ABAC отвечает: загрузили order.ID=SB-12345, у него CustomerID="user-42", текущий principal.Sub="user-99" — отказ с ForbiddenError. Это не RBAC: роль customer валидна, endpoint доступен, но этот customer не имеет права читать чужой заказ.
Для GetProduct на Sber-площадке логика та же — ABAC проверяет принадлежность только когда агрегат владелец-специфичен (например, draft-продукт принадлежит seller):
// core/product/handler/get_draft_product.go
func (h *GetDraftProductHandler) Handle(ctx context.Context, cmd GetDraftProductCommand) (ProductView, error) {
product, err := h.repo.ByID(ctx, cmd.ProductID)
if err != nil {
return ProductView{}, fmt.Errorf("load product %s: %w", cmd.ProductID, err)
}
principal := middleware.PrincipalFrom(ctx)
if err := h.policy.CheckSellerOwnership(product, principal); err != nil {
return ProductView{}, err
}
return toView(product), nil
}
Почему ABAC не на Gateway
AUTH-3 (запрет): Gateway не знает доменную модель.
Сценарий, где это ломается:
- Gateway получает
GET /orders/SB-12345, JWT валиден,sub="user-99". - Gateway пытается решить ABAC: «чей order SB-12345?» — нужно идти в БД или в order-service.
- Чтобы ответить, Gateway фактически дублирует домен и становится его клиентом.
- При изменении модели (добавили co-owners у Order) — Gateway надо обновлять параллельно с order-service.
Это двойной источник правды и нарушение инкапсуляции агрегата. Корректно: Gateway — только аутентификация, ABAC — в Domain Handler, где живёт Order.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| RBAC на Gateway | AUTH-2 | RequireRoles на chi-группе в сервисе |
| ABAC на Gateway или в контроллере | AUTH-3 | AccessPolicy.CheckOwnership в Handler |
jwt.Parse внутри Handler или UseCase | AUTH-1 | AuthN-middleware на edge роутера |
Endpoint без RequireRoles на его chi-группе | AUTH-9 | RequireRoles обязателен; нет — критическое нарушение |
| Только RBAC без ABAC для own-resource endpoints | AUTH-3 | RBAC на группе + ABAC в Handler |
if p.Roles[0] == "admin" в контроллере | AUTH-11 | AccessPolicy как отдельный тип в core/ |
| Дублирование JWT-валидации на каждом слое | AUTH-1 | один AuthN-middleware на весь роутер |
Куда дальше
- JWT validation —
JWTValidator, JWKS-keyfunc,golang-jwt/jwt/v5, коды ошибок AUTH-4..6. - RBAC: маппинг ролей —
extractRoles,RequireRoles, разрешённые роли AUTH-7..9. - ABAC: владение ресурсом —
AccessPolicy,CheckOwnership, admin-bypass AUTH-10..12. - Service-to-service — mTLS, Client Credentials Flow,
oauth2.TokenSourceAUTH-13..14. - Audit admin-команд —
audit.Logger,*_audit_log, обязателен для каждой admin-команды AUTH-15. - PII и секреты — PII не в
error.Error()иslog, секреты через envconfig AUTH-16..18. - Идемпотентность —
RequireIdempotencyKeyна money-командах AUTH-19. - Хранение токенов на клиенте — BFF HttpOnly cookie, RT rotation AUTH-20..21.