Опирается на правила:
AUTH-10…AUTH-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.Sub | AccessPolicy |
| 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-resource | AUTH-10 | AccessPolicy.CheckOwnership в Handler |
Проверка o.CustomerID != p.Sub в контроллере inline | AUTH-11 | AccessPolicy в core/<aggregate>/access.go |
Дублирование ABAC в AccessPolicy и повторно в контроллере | AUTH-11 | один из способов |
Admin без audit.Log при override | AUTH-12 | h.audit.Log(...) обязательно после Save |
| ABAC проверяет только CustomerID, игнорирует admin | AUTH-12 | isAdmin(p) OR ownership |
panic("forbidden") или log.Fatal при нарушении ABAC | AUTH-10 | *apperr.ForbiddenError → 403 |
security.PrincipalFrom(ctx) в контроллере, потом передаётся в команду полем | AUTH-11 | Principal берётся в Handler из ctx |
| Нет проверки на deleted/inactive ресурс до ABAC | AUTH-10 | сначала проверить статус, потом владение |
Куда дальше
- Аудит admin-команд — обязательная пара к admin-override (
AUTH-15). - RBAC: маппинг ролей — слой до ABAC,
RequireRolesmiddleware. - Где какая проверка — ABAC в Domain Handler, не в Gateway.
- JWT validation — как
*Principalпопадает вcontext.Context. - Service-to-service — отдельная авторизация для s2s-вызовов.
- PII и секреты — что не должно попасть в
ForbiddenError.Error(). - Хранение токенов на клиенте — HttpOnly cookie для BFF.
- Идемпотентность —
Idempotency-Keyна write-командах рядом с ABAC.