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

Роль говорит «пользователь может читать заказы». Но это не значит, что он должен читать чужие заказы. Разберём, как добавить вторую линию защиты — проверку владения ресурсом.

Проблема: RBAC недостаточно

RBAC (Role-Based Access Control) отвечает на вопрос «может ли эта роль вызывать этот endpoint». Например, роль customer вправе делать GET /orders/{id}.

Но что мешает одному покупателю подставить id чужого заказа и получить его данные? Ничего — если мы проверяем только роль, но не то, чей это заказ. Такая уязвимость называется IDOR (Insecure Direct Object Reference): подменяем идентификатор в URL и читаем чужие данные.

ABAC (Attribute-Based Access Control) добавляет второй слой: «этот конкретный покупатель вправе читать только свои заказы». Проверка строится на атрибутах — полях объекта и данных о пользователе.

Как получить данные о пользователе

В Go пользователь, сделавший запрос, описывается структурой Principal. Она появляется в context.Context после того, как JWT-middleware проверил токен:

// adapters/in/http/security/principal.go
type Principal struct {
    Sub   string   // идентификатор пользователя
    Roles []string // роли: "customer", "admin", ...
}

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

Важно: Principal всегда берётся из контекста — не из заголовков запроса вручную. Middleware сделал это до вас.

Тип AccessPolicy: одно место для ownership-логики

Проверку владения не стоит писать прямо в HTTP-обработчике. Если скопировать её на каждый endpoint (GET, PATCH, DELETE), при любом изменении правил придётся обновлять N мест и легко пропустить одно.

Решение — выделить тип AccessPolicy в доменном слое, рядом с агрегатом:

// core/order/access.go
package order

import (
    "github.com/example/app/core/apperr"
    "github.com/example/app/adapters/in/http/security"
)

type AccessPolicy struct{}

func (p *AccessPolicy) CheckOwnership(order *Order, principal *security.Principal) error {
    for _, role := range principal.Roles {
        if role == "admin" {
            return nil
        }
    }
    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) {
    o, 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(o, principal); err != nil {
        return OrderView{}, err
    }
    return toView(o), nil
}

Этот подход подходит для операций чтения и простых случаев: одно поле сравниваем, никакой сложной бизнес-логики.

Проверка внутри Handler — для записи с блокировкой

Write-операции часто загружают агрегат под блокировкой (FOR UPDATE), чтобы избежать параллельных изменений. Здесь порядок такой: сначала блокируем строку в базе, потом проверяем право.

// core/order/handler/cancel_order.go
func (h *CancelOrderHandler) Handle(ctx context.Context, cmd CancelOrderCommand) error {
    principal := security.PrincipalFrom(ctx)

    o, err := h.repo.ByIDForUpdate(ctx, cmd.OrderID)
    if err != nil {
        return fmt.Errorf("load order %s: %w", cmd.OrderID, err)
    }

    if err := h.policy.CheckOwnership(o, principal); err != nil {
        return err
    }

    if err := o.Cancel(cmd.Reason); err != nil {
        return err
    }
    return h.repo.Save(ctx, o)
}

Тот же AccessPolicy вызывается внутри Handler — после того, как агрегат в памяти. Выбор подхода зависит от сложности операции, а не от того, где удобнее.

Когда какой подход

СитуацияГде делать проверку
Чтение, агрегат не меняетсяAccessPolicy в Handler
Запись, нужен FOR UPDATEAccessPolicy внутри Handler после загрузки
Проверка нескольких агрегатовВнутри Handler

Главное — не дублировать: либо AccessPolicy, либо проверка в Handler, не оба варианта одновременно. Дублирование приводит к расхождению логики при изменении модели.

Типичная ошибка: проверка в контроллере

Соблазнительно написать проверку прямо в HTTP-обработчике — там уже есть доступ и к запросу, и к контексту:

// Плохо: проверка в HTTP-слое
func (h *OrderHTTPHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    o, _ := h.repo.ByID(r.Context(), id)

    p := security.PrincipalFrom(r.Context())
    if o.CustomerID != p.Sub {   // доменная логика в HTTP-слое
        httperr.Write(w, r, &apperr.ForbiddenError{Resource: "order", ResourceID: id})
        return
    }
}

Проблема: логика продублируется на каждом endpoint (GET, PATCH, DELETE), а контроллер начинает знать о доменных правилах. Добавление нового правила (например, совместный доступ нескольких владельцев) потребует правок в нескольких местах.

Правильно: AccessPolicy живёт в core/order/access.go, контроллер только вызывает Handler.

Admin обходит проверку, но оставляет след

Администратор может работать с любым ресурсом независимо от владельца — это нужно для поддержки и исправления ошибок. Поэтому CheckOwnership пропускает admin-ов без проверки CustomerID.

Но каждое такое действие должно записываться в журнал:

if isAdmin(principal) {
    _ = h.audit.Log(ctx, audit.LogEntry{
        ActorID:     principal.Sub,
        OccurredAt:  time.Now().UTC(),
        Action:      "order.cancel",
        AggregateID: o.ID,
        Metadata:    map[string]any{"reason": cmd.Reason},
    })
}

func isAdmin(p *security.Principal) bool {
    for _, r := range p.Roles {
        if r == "admin" {
            return true
        }
    }
    return false
}

Аудит-журнал фиксирует: кто из администраторов, когда и что сделал с чужим ресурсом. Без этого теряется прозрачность — невозможно расследовать инциденты.

ForbiddenError как ошибка-значение

В Go нарушение доступа оформляется как обычная ошибка, а не паника:

// core/apperr/forbidden.go
type ForbiddenError struct {
    Resource   string
    ResourceID string
}

func (e *ForbiddenError) Error() string {
    return fmt.Sprintf("forbidden: %s id=%s", e.Resource, e.ResourceID)
}

func (e *ForbiddenError) Kind() Kind { return Forbidden }

Слой обработки ошибок (edge-renderer) видит Kind() == Forbidden и отвечает HTTP 403. Писать w.WriteHeader(403) вручную в Handler или контроллере не нужно.

Один и тот же подход для разных агрегатов

Структура AccessPolicy одинакова — меняются только имена полей:

// core/product/access.go — продавец редактирует только свои товары
func (p *AccessPolicy) CheckEditAccess(product *Product, principal *security.Principal) error {
    for _, role := range principal.Roles {
        if role == "admin" {
            return nil
        }
    }
    if product.SellerID != principal.Sub {
        return &apperr.ForbiddenError{Resource: "product", ResourceID: product.ID}
    }
    return nil
}

// core/customer/access.go — клиент видит только свой профиль
func (p *AccessPolicy) CheckProfileAccess(customer *Customer, principal *security.Principal) error {
    for _, role := range principal.Roles {
        if role == "admin" {
            return nil
        }
    }
    if customer.ID != principal.Sub {
        return &apperr.ForbiddenError{Resource: "customer", ResourceID: customer.ID}
    }
    return nil
}

Коротко

  • RBAC проверяет роль, ABAC — конкретный ресурс. Без ABAC любой покупатель читает чужие заказы (IDOR).
  • Проверка владения живёт в AccessPolicy внутри доменного слоя, не в HTTP-контроллере.
  • Для простых операций чтения — AccessPolicy вызывается в Handler. Для записи с блокировкой — после загрузки агрегата под FOR UPDATE.
  • Нарушение доступа — ошибка-значение *ForbiddenError, не паника. Edge-renderer превращает её в HTTP 403.
  • Admin проходит без проверки владельца, но каждое такое действие пишется в аудит-журнал.
  • Один AccessPolicy на агрегат — не копировать проверки по контроллерам.

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

  • Аудит admin-команд — подробно про обязательный журнал admin-override.
  • RBAC: маппинг ролей — слой до ABAC, middleware проверки ролей.
  • JWT validation — как Principal попадает в context.Context.