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

Когда говорят «добавить авторизацию», часто подразумевают что-то одно. На самом деле это три разных вопроса, которые задаются в трёх разных местах:

  1. Кто это? — валидация JWT на входе.
  2. Есть ли у него право вообще обращаться к этому endpoint? — проверка роли.
  3. Имеет ли он право работать с конкретным ресурсом? — проверка владения.

Если смешать их в одном месте, получится либо дублирование (с риском расхождения), либо пропуск — и endpoint превращается в дыру.

Три слоя, три ответственности

В Go-приложении с chi каждый из трёх вопросов живёт в своём слое:

СлойЧто проверяетИнструмент
Gateway / API edgeподпись JWT, срок действия, издательAuthN-middleware
BFF / Application Layerесть ли у пользователя роль для этого endpointRequireRoles на chi-группе
Domain Handlerпринадлежит ли ресурс этому пользователюAccessPolicy в core/

Gateway — кто этот пользователь

Первый слой отвечает только на один вопрос: «токен настоящий?». AuthN-middleware стоит в самом начале цепочки на всём роутере. Он проверяет подпись JWT, срок действия и другие технические параметры. При успехе кладёт структуру *Principal в context.Context, чтобы следующие слои могли её прочитать.

// 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) // невалидный JWT → 401
                return
            }
            ctx := context.WithValue(r.Context(), principalKey, principal)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

Важно: Gateway не знает, какие роли нужны для конкретного endpoint, и не знает ничего о доменной модели. Его задача — только установить личность.

BFF — может ли этот пользователь обратиться к endpoint

Второй слой проверяет роль. Например, POST /orders может только customer, а POST /products — только admin. Это RBAC (Role-Based Access Control) — контроль по роли, без знания конкретного ресурса.

RequireRoles вешается на chi-группу. Если роль не подходит — возвращается 403 Forbidden, до Handler запрос не доходит.

// adapters/in/http/router.go

r := chi.NewRouter()
r.Use(middleware.AuthN(jwtValidator)) // первым, на всём роутере

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

r.Group(func(r chi.Router) {
    r.Use(middleware.RequireRoles("admin"))
    r.Post("/orders/{id}/cancel", h.AdminCancelOrder)
    r.Post("/products", h.CreateProduct)
})

Реализация RequireRoles читает *Principal из контекста (который туда положил AuthN) и проверяет совпадение роли:

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

Обратите внимание: RequireRoles без AuthN перед ним не работает. Если AuthN не положил *Principal в контекст, PrincipalFrom вернёт nil, и middleware вернёт 401.

Domain Handler — принадлежит ли ресурс этому пользователю

Третий слой — самый тонкий. Пользователь уже прошёл первые два: токен валиден, роль подходит. Но customer не должен видеть чужой заказ. Это ABAC (Attribute-Based Access Control) — проверка по атрибутам конкретного ресурса.

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 // admin видит всё
        }
    }
    if order.CustomerID != principal.Sub {
        return &apperr.ForbiddenError{
            Resource:   "order",
            ResourceID: order.ID,
        }
    }
    return nil
}

Handler вызывает политику после загрузки агрегата из репозитория:

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

Сценарий: загрузили order.ID=SB-12345, у него CustomerID="user-42", а в токене sub="user-99" — отказ с ForbiddenError. Роль у пользователя правильная, endpoint доступен, но конкретный ресурс не его.

Почему ABAC нельзя делать на Gateway

Это частая ошибка: попытка проверить владение ресурсом прямо на входе, в middleware.

Проблема в том, что Gateway не знает доменную модель. Чтобы ответить на вопрос «чей это заказ?», ему пришлось бы идти в базу данных или вызывать другой сервис. Фактически — дублировать домен. При любом изменении модели (например, у заказа появляется несколько владельцев) Gateway нужно обновлять параллельно — это двойной источник правды.

Правило простое: ABAC живёт там, где живёт агрегат, потому что только он знает свои правила владения.

Частые ошибки

RBAC на Gateway вместо группы роутов. Gateway не знает, какой endpoint для какой роли. Ролевая проверка принадлежит слою BFF — в RequireRoles на chi-группе.

ABAC в контроллере вместо AccessPolicy. Проверка if p.Roles[0] == "admin" прямо в контроллере — это процедурный код без изоляции. Если логика изменится, менять придётся в каждом месте. AccessPolicy как отдельный тип в core/ — единое место для всех правил владения.

JWT-валидация в Handler или UseCase. Если jwt.Parse вызывается в нескольких местах, при смене алгоритма или ключей придётся менять всё. AuthN-middleware — одно место на весь роутер.

Только RBAC без ABAC для own-resource endpoints. Роль customer разрешает обратиться к GET /orders/{id}, но не означает, что заказ принадлежит этому пользователю. RBAC на группе + ABAC в Handler — оба слоя обязательны.

Путаница между AuthError (401) и ForbiddenError (403). Невалидный токен — 401 («кто вы?»). Валидный токен, но нет доступа — 403 («вас я знаю, но не пущу»). Возвращать не тот код — ломает клиентов и маскирует реальную причину.

Коротко

  • Auth — это три разных проверки: кто (JWT), какая роль (RBAC), чей ресурс (ABAC).
  • AuthN-middleware стоит первым на всём роутере и кладёт *Principal в контекст.
  • RequireRoles вешается на chi-группу и проверяет роль до входа в Handler.
  • AccessPolicy живёт в core/<aggregate>/ и проверяет владение конкретным агрегатом.
  • RequireRoles без AuthN перед ним не работает — PrincipalFrom вернёт nil.
  • ABAC на Gateway невозможен без дублирования доменной модели — не делать.
  • Ошибки разные: невалидный токен → 401, нет доступа → 403.

Что почитать дальше