handbookНаписать

patterns/enterprise/specification/practice-access/README.md · практика · 4 языка · 35 тестов

Практика: доступ к заказу в админке (Access rules) ​

Правило "кто может редактировать заказ" собирается из маленьких спецификаций над парой пользователь и заказ, и при отказе называет условие, которое не выполнено.

Базовый пример: Спецификация. Там спецификация отвечает только "да или нет", и в минусах честно записано: почему правило не выполнено, она сама не скажет. В правилах доступа без этого не обойтись: менеджеру нужно увидеть не "403", а "заказ уже оплачен".

Задача ​

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

Сначала это был if из четырёх условий в контроллере и копия того же if в шаблоне, который прячет кнопку "Редактировать". Копии разошлись, а в поддержку пошли вопросы "почему я не могу поправить заказ", на которые ответ был только в коде.

Что урезано ​

  • Пользователь - номер и одна роль, без групп и наследования прав.
  • Заказ - номер, менеджер и статус строкой (new, paid).
  • Время запроса передаётся явно и сравнивается по часам в UTC: часовые пояса и выходные не учитываются.
  • Правило собирается в коде из параметров, а не из конфига или таблицы прав.

Решение ​

Кандидат спецификации - не один объект, а запрос AccessRequest: пользователь, заказ и время. Простые правила HasRole, OwnsOrder, OrderStatus и WithinHours смотрят каждое на свою часть запроса, комбинаторы and, or, not те же, что в базовом примере.

Новое - метод explain, который возвращает Verdict: разрешено или нет и список невыполненных условий. Каждое правило умеет себя назвать (describe): role is manager, user owns the order, order is paid, within working hours 09-18. Ответ "да или нет" остаётся: isSatisfiedBy - это explain(...).allowed, поэтому шаблону с кнопкой хватит булева ответа, а контроллеру достанется объяснение.

Правило редактирования собирается один раз в OrderAccess:

text
(role is manager and user owns the order and not (order is paid) and within working hours)
or role is admin

OrderAccess.ensureCanEdit бросает AccessDenied со списком причин, а контроллер превращает его в 403 с текстом для пользователя.

Структура ​

throws

checks

AccessRequest

+user: User

+order: Order

+at: time

Verdict

+allowed: bool

+unmet: string[]

«abstract»

Specification

+explain(request) : Verdict

+describe() : string

+isSatisfiedBy(request) : bool

+and(other) : Specification

+or(other) : Specification

+not() : Specification

«abstract»

Condition

#holds(request) : bool

HasRole

OwnsOrder

OrderStatus

WithinHours

AndSpecification

OrSpecification

NotSpecification

OrderAccess

-canEdit: Specification

+withWorkingHours(from, to) : OrderAccess

+ensureCanEdit(request)

AccessDenied

+reasons: string[]

Condition - общая часть простых правил: проверить одно условие и при отказе вернуть своё имя. В Go это одна структура condition с описанием и функцией, а простые правила - конструкторы.

Как объясняется отказ ​

У каждого комбинатора своё правило объяснения:

  • and останавливается на первом невыполненном условии и возвращает его. Менеджеру без своего заказа незачем читать ещё и про рабочие часы: исправить можно только одно, и порядок в правиле задаёт, что важнее.
  • or при отказе возвращает причины всех веток: каждая - отдельный способ получить доступ. "within working hours 09-18; role is admin" читается как "вернитесь в рабочее время или попросите администратора".
  • not при отказе называет себя целиком: not (order is paid).

Порядок причин детерминирован и совпадает с порядком в правиле, поэтому тесты сравнивают список целиком.

Как это собирается ​

OrderAccess создаёт DI-контейнер админки при старте: рабочие часы 9-18 приходят из настроек, withWorkingHours собирает из них правило. Контроллер PUT /admin/orders/{id} берёт текущего пользователя из сессии, заказ из репозитория, время из часов приложения и зовёт ensureCanEdit. Время передаётся явно, поэтому правило не читает часы само и одинаково ведёт себя в тестах и в проде.

В примере пять запросов. Ann правит свой новый заказ днём - 200. Вечером тот же запрос - 403 с причиной про рабочие часы. Свой оплаченный A-2 - 403 с not (order is paid). Bob пытается править заказ Ann - user owns the order. Администратор правит оплаченный заказ ночью - 200. В каждом отказе вторая причина role is admin: это вторая ветка or.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

ЯзыкОсобенность
PHPabstract readonly class Specification с and/or/not, общий Condition с holds(); цепочка new HasRole('manager')->and(...) без скобок вокруг new из PHP 8.4
GoSpecification - интерфейс из Explain и Describe; And и Or вариадические, поэтому правило пишется плоско; отказ - *AccessDeniedError, достаётся через errors.As
PythonКомбинаторы - операторы &, |, ~; Verdict - замороженный dataclass с кортежем причин, сравнивается целиком
TypeScriptОбобщённый Specification<T>, комбинаторы не экспортируются; время - Date, часы берутся через getUTCHours

Запуск: make test T=patterns/enterprise/specification/practice-access

Что проверяют тесты ​

Тесты одинаковы во всех языках. Таблица из шести запросов к заказу A-1 менеджера Ann сверяет Verdict целиком, вместе со списком причин: владелец днём на новом заказе - разрешено; владелец в 20:00 - within working hours 09-18 и role is admin; владелец на оплаченном - not (order is paid) и role is admin; чужой менеджер - user owns the order; бухгалтер - role is manager (дальше and не идёт); администратор на оплаченном заказе в 23:00 - разрешено. В каждом случае isSatisfiedBy совпадает с allowed: булев ответ и объяснение не расходятся.

Второй тест проверяет, что AccessDenied несёт список причин и текст с пользователем и заказом - то, что уйдёт в ответ и в лог. Третий собирает политику с часами 8-21 и показывает, что правило - значение из настроек: тот же запрос в 20:00 проходит без правки кода.

Где граница ​

  • Права хранятся в данных. Если роли и разрешения заводит администратор в интерфейсе, правило перестаёт быть кодом: нужна модель ролей и разрешений (RBAC) или атрибутов (ABAC), а спецификации остаются для условий, которые в таблицу не ложатся, вроде "пока не оплачен".
  • Нужен отбор, а не проверка. Список "заказы, которые я могу править" на тысячах заказов надо отбирать в базе. Это перевод спецификации в SQL - задача первой практики.
  • Объяснения для людей. Тексты describe здесь технические. Для интерфейса их переводят по коду причины, а не по строке, иначе любая правка текста ломает перевод.
  • Избыточность. Одно правило из двух условий в одном контроллере - это if с понятным сообщением об ошибке; дерево спецификаций окупается, когда правило собирают из частей в нескольких местах или показывают причину отказа.

Отличия между языками ​

  • PHP - простые правила наследуют Condition и пишут только holds и describe; explain для них один. readonly на абстрактном классе делает неизменяемым всё дерево.
  • Go - классы не нужны: простое правило - значение condition с описанием и замыканием. Вариадические And(a, b, c, d) читаются лучше вложенных бинарных. isSatisfiedBy отдельно нет: Explain(r).Allowed короче, чем ещё один метод в интерфейсе.
  • Python - операторы делают правило выражением, но скобки обязательны: & связывает сильнее |. Причины - кортеж, поэтому Verdict неизменяем и сравнивается ==.
  • TypeScript - Date хранит момент времени, а не часы на стенке: getHours зависит от пояса машины, поэтому правило берёт getUTCHours. В проекте пояс магазина передавали бы явно.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS