Опирается на правила: AUTH-10AUTH-12 из контракта Auth Patterns → раздел 4. ABAC: владение ресурсом.

Важно знать

  • ABAC обязателен для каждой команды/запроса, работающего с агрегатом по id.
  • В Go: выделенный тип AccessPolicy в core/<aggregate>/access.go — единое место ownership-логики (AUTH-11).
  • Альтернатива — проверка прямо в Handler, когда нужен load + lock (FOR UPDATE) до проверки права.
  • Ошибки — значения: нарушение ABAC возвращает *apperr.ForbiddenError, не паника, не ResponseStatusException.
  • Роль admin обходит ABAC (полный доступ), но каждое действие обязательно пишется в audit log (AUTH-12).
  • Без ABAC RBAC-only endpoint становится IDOR: любой держатель JWT с ролью customer читает заказы чужих пользователей.
  • Principal берётся из context.Context через security.PrincipalFrom(ctx) — не из заголовка запроса в Handler.

RBAC говорит «роль customer вправе вызывать GET /orders/{id}». ABAC добавляет второй слой: «этот customer вправе читать только свои orders». Без ABAC любой держатель JWT с правильной ролью получает доступ ко всем ресурсам — классический IDOR (Insecure Direct Object Reference).

Два способа

AUTH-10 допускает два подхода: выделенный AccessPolicy или проверку внутри Handler. Оба корректны — выбор зависит от сложности логики.

Способ 1: AccessPolicy для простых случаев

AccessPolicy — отдельный тип в core/<aggregate>/access.go. Один метод на тип проверки.

// 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   // AUTH-12: admin проходит, audit пишет Handler
        }
    }
    if order.CustomerID != principal.Sub {
        return &apperr.ForbiddenError{Resource: "order", ResourceID: order.ID}
    }
    return nil
}

Handler загружает агрегат, потом вызывает политику:

// core/order/handler/get_order.go
package handler

import (
    "context"
    "fmt"

    "github.com/example/app/adapters/in/http/security"
    "github.com/example/app/core/order"
)

type GetOrderHandler struct {
    repo   order.Repository
    policy *order.AccessPolicy
}

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 {   // AUTH-10
        return OrderView{}, err
    }
    return toView(o), nil
}

Подходит для read-операций и простых ownership checks (сравнение одного поля, никакой бизнес-логики кроме сравнения).

Способ 2: проверка внутри Handler для write с блокировкой

Write-команды загружают агрегат под FOR UPDATE до проверки владения — здесь AccessPolicy вызывается уже внутри Handler, когда агрегат в памяти:

// core/order/handler/cancel_order.go
package handler

import (
    "context"
    "fmt"
    "time"

    "github.com/example/app/adapters/in/http/security"
    "github.com/example/app/core/apperr"
    "github.com/example/app/core/audit"
    "github.com/example/app/core/order"
)

type CancelOrderHandler struct {
    repo   order.Repository
    policy *order.AccessPolicy
    audit  audit.Logger
}

func (h *CancelOrderHandler) Handle(ctx context.Context, cmd CancelOrderCommand) error {
    principal := security.PrincipalFrom(ctx)

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

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

    prevStatus := o.Status
    if err := o.Cancel(cmd.Reason); err != nil {
        return err
    }
    if err := h.repo.Save(ctx, o); err != nil {
        return fmt.Errorf("save order %s: %w", o.ID, err)
    }

    if isAdmin(principal) {
        return h.audit.Log(ctx, audit.LogEntry{   // AUTH-12: audit обязателен
            ActorID:     principal.Sub,
            OccurredAt:  time.Now().UTC(),
            Action:      "order.cancel",
            AggregateID: o.ID,
            Metadata:    map[string]any{"reason": cmd.Reason, "prevStatus": prevStatus},
        })
    }
    return nil
}

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

Подходит для сложных случаев: ownership + проверка состояния агрегата, multi-aggregate условия.

Когда какой способ

СлучайСпособ
Простая ownership order.CustomerID == principal.SubAccessPolicy
Read-endpoint, агрегат не модифицируетсяAccessPolicy в Handler
Write-endpoint, нужен FOR UPDATE до проверки праваHandler-check
Composite: ownership + бизнес-правило состоянияHandler-check
Несколько агрегатов (Product + Seller)Handler-check

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

ABAC централизована, не размазана

AUTH-11 требует, чтобы ABAC-логика жила в выделенном AccessPolicy или Handler — не копировалась по контроллерам.

Неверно:

// ПЛОХО: ABAC размазан по контроллерам
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 {   // проверка прямо в контроллере
        httperr.Write(w, r, &apperr.ForbiddenError{Resource: "order", ResourceID: id})
        return
    }
    // ...
}

Что не так: логика проверки дублируется на каждом endpoint (GET, PATCH, DELETE); при добавлении co-ownership или seller-доступа нужно обновлять N мест; контроллер делает доменную проверку.

Корректно: AccessPolicy в core/order/access.go — единое место, одно изменение покрывает все операции над Order.

Admin обходит ABAC + audit

AUTH-12: admin может всё, но след остаётся.

// core/order/access.go — admin выходит первым
func (p *AccessPolicy) CheckOwnership(order *Order, principal *security.Principal) error {
    for _, role := range principal.Roles {
        if role == "admin" {
            return nil   // проходит без проверки CustomerID
        }
    }
    if order.CustomerID != principal.Sub {
        return &apperr.ForbiddenError{Resource: "order", ResourceID: order.ID}
    }
    return nil
}

В Handler после сохранения — обязательная запись в order_audit_log:

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

Admin может отменить заказ клиента (support, compliance, ops), но order_audit_log фиксирует «admin-7 отменил order-12345 в момент X». Подробнее — Аудит admin-команд.

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

В Go нарушение ABAC — ошибка-значение, не паника:

// 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 }   // mapKind → 403

Edge-renderer маппит Kind() == Forbidden в HTTP 403. Самостоятельно писать w.WriteHeader(403) в Handler или контроллере — запрещено.

Применение на доменах

Product / Seller — доступ к редактированию карточки только продавца-владельца:

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

Customer / Profile — клиент видит только свой профиль:

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

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

Что запрещено

АнтипаттернПравилоЧто взамен
Endpoint с RequireRoles, но без ABAC для own-resourceAUTH-10AccessPolicy.CheckOwnership в Handler
Проверка o.CustomerID != p.Sub в контроллере inlineAUTH-11AccessPolicy в core/<aggregate>/access.go
Дублирование ABAC в AccessPolicy и повторно в контроллереAUTH-11один из способов
Admin без audit.Log при overrideAUTH-12h.audit.Log(...) обязательно после Save
ABAC проверяет только CustomerID, игнорирует adminAUTH-12isAdmin(p) OR ownership
panic("forbidden") или log.Fatal при нарушении ABACAUTH-10*apperr.ForbiddenError → 403
security.PrincipalFrom(ctx) в контроллере, потом передаётся в команду полемAUTH-11Principal берётся в Handler из ctx
Нет проверки на deleted/inactive ресурс до ABACAUTH-10сначала проверить статус, потом владение

Куда дальше

  • Аудит admin-команд — обязательная пара к admin-override (AUTH-15).
  • RBAC: маппинг ролей — слой до ABAC, RequireRoles middleware.
  • Где какая проверка — ABAC в Domain Handler, не в Gateway.
  • JWT validation — как *Principal попадает в context.Context.
  • Service-to-service — отдельная авторизация для s2s-вызовов.
  • PII и секреты — что не должно попасть в ForbiddenError.Error().
  • Хранение токенов на клиенте — HttpOnly cookie для BFF.
  • Идемпотентность — Idempotency-Key на write-командах рядом с ABAC.