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

БЕЗОПАСНОСТЬ И АВТОРИЗАЦИЯ ABAC

ABAC и владение ресурсом в Go

Go · AccessPolicy

Как проверить владельца, tenant и состояние ресурса в доменной политике и не оставить IDOR после ролевой проверки.

1. RBAC разрешает класс операций, ABAC - объект

1 / 5

Право читать заказы не означает право читать любой заказ. Атрибуты principal и ресурса определяют доступ.

  • userID сравнивается с ownerID.
  • Tenant сравнивается с tenantID.
  • Состояние ресурса влияет на операцию.
PrincipaluserID · roles · tenantResourceownerID · tenant · stateAccessPolicy

2. Ресурс загружается до политики

2 / 5

Репозиторий возвращает заказ, сценарий передаёт его в AccessPolicy вместе с principal. Проверка происходит до изменения.

  • Отсутствие ресурса - not found.
  • Чужой ресурс - forbidden.
  • Изменение выполняется только после allow.
Load orderAccessPolicyAllowUpdateЧужой owner → ForbiddenError

3. Политика возвращает ошибку-значение

3 / 5

AccessPolicy не пишет HTTP. Она возвращает ForbiddenError с предметным кодом, а граница переводит его в 403.

  • Правило тестируется обычным unit-тестом.
  • Домен не зависит от chi.
  • Отказ нельзя случайно проигнорировать.
AccessPolicyno HTTP dependencyForbiddenErrorboundary → 403Доменное правило, транспортный статус

4. Admin override - отдельное решение с аудитом

4 / 5

Поддержка может пройти мимо владения только по явной роли и правилу. Каждый override фиксирует actor, action, resource и reason.

  • Override не скрывается в if admin.
  • Запись аудита атомарна с операцией.
  • Причина обязательна для расследования.
Admin actionOverride policyAudit + updateОдна транзакция, видимый след

5. IDOR проверяется чужим идентификатором

5 / 5

Тест подставляет ID существующего чужого заказа и ожидает отказ. Проверки только несуществующего ID недостаточно.

  • Свой ресурс - success.
  • Чужой ресурс - denied.
  • Admin - success и audit record.
owner + own order → allowuser + foreign order → forbiddenadmin + foreign order → allow + audit

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

Проблема: 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 проверил токен:

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

type principalKey struct{}

func WithPrincipal(ctx context.Context, p *Principal) context.Context {
    return context.WithValue(ctx, principalKey{}, p)
}

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

Важно: Principal всегда берётся из контекста — не из заголовков запроса вручную. Middleware сделал это до вас через WithPrincipal. Пакет лежит в core/, а не в адаптерах: его импортирует и 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/core/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. Меняется только момент вызова — для чтения сразу после загрузки, для записи после загрузки под блокировкой.

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

Соблазнительно написать проверку прямо в 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) {
    err := 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},
    })
    if err != nil {
        return fmt.Errorf("audit admin cancel: %w", err) // без записи в журнал операция не считается выполненной
    }
}

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

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

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

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

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

Слой обработки ошибок (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, не panic. Edge-renderer превращает её в HTTP 403.
  • Admin проходит без проверки владельца, но каждое такое действие пишется в аудит-журнал.
  • Один AccessPolicy на агрегат — не копировать проверки по контроллерам.

Что пощупать

В каталоге практикума remodov/marketplace-system-go владение проверяется в обработчике сценария после загрузки товара под блокировкой: requireOwnership пропускает владельца и администратора, остальным отдаёт OWN_PRODUCT_REQUIRED как 404, а не 403 — чужой товар для продавца не существует. Роли приходят из Principal в контексте, который кладёт middleware Authenticate; в режиме AUTH_MODE=local токен — строка role.uuid, в бою — JWT с ролями из realm_access.roles.

Код: usecase/ownership.go, security/principal.go, http/auth.go.

Сделаем сами

Ветка step-07-adult-catalog — смена цены вынута из ядра, агрегата и адаптера, шесть тестов TestChangePrice_* красные; вернуть её по слоям так, чтобы архитектурный тест остался зелёным. Тест «чужой продукт — 404 OWN_PRODUCT_REQUIRED» среди них.

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