Практика: доступ к заказу в админке (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:
(role is manager and user owns the order and not (order is paid) and within working hours)
or role is adminOrderAccess.ensureCanEdit бросает AccessDenied со списком причин, а контроллер превращает его в 403 с текстом для пользователя.
Структура
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 в панели кода.
Реализации
| Язык | Особенность |
|---|---|
| PHP | abstract readonly class Specification с and/or/not, общий Condition с holds(); цепочка new HasRole('manager')->and(...) без скобок вокруг new из PHP 8.4 |
| Go | Specification - интерфейс из 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. В проекте пояс магазина передавали бы явно.
Связанные темы
- Спецификация (базовый пример)
- Практика: аудитория рассылки - та же спецификация, переведённая в SQL
WHERE - Компоновщик (Composite) - объяснение отказа собирается обходом того же дерева