Роль говорит «пользователь может читать заказы». Но это не значит, что он должен читать чужие заказы. Разберём, как добавить вторую линию защиты — проверку владения ресурсом.
Проблема: 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 UPDATE | AccessPolicy внутри 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.