@via-profit/ability 3.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,1177 +2,1173 @@
2
2
 
3
3
  > Набор сервисов, частично реализующих
4
4
  > принцип [Attribute Based Access Control](https://en.wikipedia.org/wiki/Attribute-based_access_control)
5
+ > Пакет позволяет описывать правила, объединять их в группы, формировать политики и применять их к данным для определения разрешений.
5
6
 
6
- Этот сервис позволяет создавать правила и политики, применять их к данным и проверять доступ на их основе.
7
+ ## Для чего
8
+
9
+ Пакет задуман как **лёгкая и предельно простая альтернатива** тяжёлым системам управления доступом.
10
+ Без сложных конфигураций, без зависимостей — только минимальный набор инструментов, который позволяет описывать правила и политики в максимально простом DSL.
7
11
 
8
12
  ## Содержание
9
13
 
10
- - [Обзор](#обзор)
11
- - [Состав пакета](#состав-пакета)
12
- - [Основные принципы](#основные-принципы)
13
- - [Правила](#правила)
14
- - [Создание правила](#создание-правила)
15
- - [Проверка правила](#проверка-правила)
16
- - [Группы правил](#группы-правил)
17
- - [Создание группы правил](#создание-группы-правил)
18
- - [Проверка группы правил](#проверка-группы-правил)
19
- - [Политики](#политики)
20
- - [Создание политики](#создание-политики)
21
- - [Проверка политики](#проверка-политики)
22
- - [Управление политиками](#управление-политиками)
23
- - [Зачем нужен AbilityResolver?](#зачем-нужен-abilityresolver)
24
- - [Использование wildcard (*) в действиях](#использование-wildcard--в-действиях)
25
- - [Как это работает](#как-это-работает)
26
- - [Проверка соответствия действия](#проверка-соответствия-действия)
27
- - [Методы AbilityResolver](#методы-abilityresolver)
28
- - [Интеграция с TypeScript](#интеграция-с-typescript)
29
- - [Как формируется итоговое решение?](#как-формируется-итоговое-решение)
30
- - [Когда использовать Resolver?](#когда-использовать-resolver)
31
- - [API Reference](#api-reference)
32
- - [Класс AbilityCode](#класс-abilitycode)
33
- - [Класс AbilityMatch](#класс-abilitymatch)
34
- - [Класс AbilityCondition](#класс-abilitycondition)
35
- - [Класс AbilityCompare](#класс-abilitycompare)
36
- - [Класс AbilityPolicyEffect](#класс-abilitypolicyeffect)
37
- - [Класс AbilityRule](#класс-abilityrule)
38
- - [Класс AbilityRuleSet](#класс-abilityruleset)
39
- - [Класс AbilityPolicy](#класс-abilitypolicy)
40
- - [Класс AbilityResolver](#класс-abilityresolver)
41
- - [Класс AbilityExplain](#класс-abilityexplain)
42
- - [Класс AbilityParser](#класс-abilityparser)
43
- - [Классы ошибок](#классы-ошибок)
44
- - [Рекомендации по использованию](#рекомендации-по-использованию)
45
- - [Именование экшенов](#именование-экшенов)
46
- - [Структура данных](#структура-данных)
47
- - [Проектирование политик](#проектирование-политик)
14
+ - [Быстрый старт](#быстрый-старт)
15
+ - [Основные положения](#основные-положения)
16
+ - [DSL](#dsl)
17
+ - [Объединение политик](#объединение-политик)
18
+ - [Environment политик](#environment-политик)
19
+ - [Генератор типов для TypeScript](#генератор-типов-для-typescript)
48
20
  - [Отладка политик](#отладка-политик)
49
- - [Лицензия](#лицензия)
21
+ - [Решение проблем](#решение-проблем)
22
+ - [Рекомендации по проектированию](#рекомендации-по-проектированию)
23
+ - [Примеры](#примеры)
24
+ - [Производительность](#производительность)
25
+ - [Api-Reference](./docs/ru/api.md)
50
26
 
51
- ---
52
27
 
53
- ## Обзор
54
-
55
- ### Состав пакета
56
-
57
- - **`AbilityRule`** — класс отдельного правила
58
- - **`AbilityRuleSet`** — класс группы правил
59
- - **`AbilityPolicy`** — класс политики
60
- - **`AbilityResolver`** — управление политиками
61
- - **`AbilityMatch`** — константы состояния правил (`pending`, `match`, `mismatch`)
62
- - **`AbilityCompare`** — способы сравнения (`or`, `and`)
63
- - **`AbilityCondition`** — методы вычисления (`equal`, `not_equal`, `more_than`, `less_than`, `in`, `not_in` и др.)
64
- - **`AbilityPolicyEffect`** — эффекты политики (`deny`, `permit`)
65
- - **`AbilityParser`** — парсер конфигурационных правил (JSON) и генератор `Typescript` типов
66
- - **`AbilityError`** — инстанс ошибок
67
- - **`AbilityExplain`** — вспомогательный инструмент, который позволяет получить человекочитаемое объяснение того, почему конкретное действие разрешено или запрещено текущей конфигурацией Ability
68
-
69
- ### Основные принципы
70
-
71
- Работа сервиса основана на формировании **правил**, объединении их в **политики** и проверке доступа с их помощью.
72
-
73
- Пример: необходимо **запретить доступ** пользователям, связанным с отделом менеджеров, **за исключением администраторов**.
74
-
75
- - Менеджеры — если их отдел `managers` или есть роль `manager`
76
- - Администраторы — пользователи с ролью `administrator`
77
-
78
- Структура политики:
79
-
80
- ![ability-01.png](./assets/ability-01.drawio.png)
81
-
82
- JSON-конфигурация:
83
-
84
- ```json
85
- {
86
- "name": "Запрет доступа для менеджеров (исключение: администраторы)",
87
- "compareMethod": "and",
88
- "action": "order.update",
89
- "effect": "deny",
90
- "ruleSet": [
91
- {
92
- "name": "Менеджеры",
93
- "compareMethod": "or",
94
- "rules": [
95
- {
96
- "name": "Отдел managers",
97
- "subject": "user.department",
98
- "resource": "managers",
99
- "condition": "in"
100
- },
101
- {
102
- "name": "Роль manager",
103
- "subject": "user.roles",
104
- "resource": "manager",
105
- "condition": "in"
106
- }
107
- ]
108
- },
109
- {
110
- "name": "Не администраторы",
111
- "compareMethod": "and",
112
- "rules": [
113
- {
114
- "name": "Нет роли administrator",
115
- "subject": "user.roles",
116
- "resource": "administrator",
117
- "condition": "not in"
118
- }
119
- ]
120
- }
121
- ]
122
- }
28
+ ## Быстрый старт
29
+
30
+ Установить пакет, написать DSL, вызвать парсер, запустить резолвер.
31
+
32
+ ### Установка
33
+
34
+ ```bash
35
+ npm install @via-profit/ability
36
+ ```
37
+
38
+ ```bash
39
+ yarn add @via-profit/ability
123
40
  ```
124
41
 
125
- Применение политики:
42
+ ```bash
43
+ pnpm add @via-profit/ability
44
+ ```
45
+
46
+
47
+ ### Пример: запретить доступ к `passwordHash` всем, кроме владельца
48
+
49
+ Допустим, у нас есть пользовательские данные:
126
50
 
127
51
  ```ts
128
- const jsonConfig = { ... };
129
- AbilityPolicy.parse(jsonConfig).check({
130
- user: {
131
- department: 'managers',
132
- roles: ['manager', 'coach'],
133
- }
134
- });
52
+ const user = {
53
+ id: '1',
54
+ login: 'user-001',
55
+ passwordHash: '...',
56
+ };
135
57
  ```
136
58
 
137
- ---
59
+ Нужно запретить чтение `passwordHash` всем, кроме самого пользователя.
60
+
61
+ #### DSL‑политика
138
62
 
139
- ## Правила
63
+ На языке политик это выглядит так:
140
64
 
141
- **Правила** выполняют условие проверки и возвращают результат. **Основная цель** - выполнить сравнение переданных
142
- значений субъекта и ресурса, а затем вернуть результат такого сравнения.
65
+ ```
66
+ deny permission.user.passwordHash if any:
67
+ viewer.id is not equals owner.id
68
+ ```
143
69
 
144
- ### Создание правила
70
+ **Пояснение:**
145
71
 
146
- Создать правило можно двумя способами: создание через конструктор класса и парсинг JSON-конфига правила.
72
+ - `deny` эффект политики (запретить доступ)
73
+ - `permission.user.passwordHash` — ключ разрешения.
74
+ - `if any:` — начало блока условий
75
+ - `viewer.id is not equals owner.id` — правило: если идентификатор запрашивающего не равен идентификатору владельца
147
76
 
148
- При создании необходимо указать следующие параметры:
149
77
 
150
- - **id** - `string` Уникальный идентификатор.
151
- - **name** - `string` Название правила.
152
- - **condition** - `AbilityCondition` Определяет условия сравнения переданных данных
153
- - **subject** - `string` Dot notation путь в проверяемом субъекте, например: `user.name`.
154
- - **resource** - `string | number | boolean | (string | number)[]` Dot notation путь в проверяемом ресурсе, например:
155
- `user.name` или значение, которое может быть строкой, числом, булеан значением или массивом строк, или чисел.
78
+ Если `viewer.id` не равен `owner.id`, правило считается выполненным, и политика возвращает `deny` доступ запрещён. Если же идентификаторы совпадают (т.е. пользователь запрашивает свои собственные данные), правило не срабатывает, и доступ разрешается.
156
79
 
157
- _Создание правила через конструктор класса:_
80
+ _Замечание: Ключ разрешения формируется по принципу: `permission.` + ваш кастомный ключ в формате **dot notation**, например, ключ `foo.bar.baz` в DSL будет иметь вид `permission.foo.bar.baz`_
81
+
82
+ #### Проверка в коде
158
83
 
159
84
  ```ts
160
- import { AbilityRule, AbilityCondition } from '@via-profit/ability';
161
-
162
- const rule = new AbilityRule({
163
- id: '<rule-id>',
164
- name: 'Пользователь из отдела managers',
165
- subject: 'user.department',
166
- resource: 'managers',
167
- condition: AbilityCondition.equal
168
- });
85
+ import { AbilityDSLParser, AbilityResolver } from '@via-profit/ability';
169
86
 
170
- // сокращённая запись
171
- const rule2 = AbilityRule.equal(
172
- 'user.department', // subject
173
- 'managers' // resource
174
- );
87
+ const dsl = `
88
+ deny permission.user.passwordHash if any:
89
+ viewer.id is not equals owner.id
90
+ `;
175
91
 
92
+ const policies = new AbilityDSLParser(dsl).parse(); // получение политик
93
+ const resolver = new AbilityResolver(policies); // создание резолвера
94
+
95
+ resolver.enforce('user.passwordHash', {
96
+ viewer: { id: '1' },
97
+ owner: { id: '2' },
98
+ }); // выбросит ошибку — доступ запрещён
176
99
  ```
100
+ В `enforce` передаётся ключ без префикса `permission.` — он автоматически удаляется парсером.
177
101
 
178
- _Создание правила через парсинг JSON-конфигурации:_
102
+ ## Основные положения
179
103
 
180
- ```ts
181
- import { AbilityRule } from '@via-profit/ability';
182
-
183
- const rule = AbilityRule.parse({
184
- "id": "<rule-id>",
185
- "name": "Пользователь из отдела managers",
186
- "subject": "user.department",
187
- "resource": "managers",
188
- "condition": "="
189
- });
104
+ Тезисно перечислим основные положения, которые необходимо знать перед тем как начать пользоваться пакетом:
105
+
106
+ 1. Резолвер (`AbilityResolver`) настроен по принципу `Default Deny`. Это значит, что если ни одна политика не сработала, то результат будет `deny` ([подробнее здесь](#решение-проблем)). Чтобы избежать неожиданного `deny`, убедитесь, что существует хотя бы одна `permit`‑политика, которая может совпасть. Только после этого добавляйте `deny`‑политики.
107
+ 2. Политики применяются последовательно. Если несколько политик совпали, результат определяется последней совпавшей политикой.
108
+ 3. Правила выполняются последовательно.
109
+ 4. В группе правил (`RuleSet`) с оператором сравнения `all` дальнейшее выполнение правил прекращается как только первое же правило вернёт `mismatch`.
110
+ 5. Для составления политик используйте [DSL](#dsl) — это проще и удобнее
111
+ 6. Для хранения политик на сервере используйте JSON. Политики возможно экспортировать в JSON и импортировать из JSON
112
+ 7. Чаще всего следует опираться на утверждение если разрешение не выдано явно → доступ запрещён.
113
+ 8. Используйте встроенный кэш только в случаях, если ваши политики неимоверно сложны и содержат большое количество правил
114
+
115
+ ---
116
+
117
+ ## DSL
118
+
119
+ > DSL - Domain-Specific Language
120
+
121
+ Ability DSL — это декларативный язык для описания политик доступа.
122
+ Он позволяет определять правила в человекочитаемой форме, используя простые конструкции: *политики*, *группы*, *правила* и *аннотации*.
123
+
124
+ ### Структура политики
125
+
126
+ Политика состоит из:
190
127
 
191
128
  ```
129
+ <effect> <permission> if <all|any>:
130
+ <group>...
131
+ ```
192
132
 
193
- ### Проверка правила
133
+ Где:
194
134
 
195
- Для проверки правила следует вызвать метод `check` класса `AbilityRule` передав объект проверяемого ресурса. Этот метод
196
- вернёт экземпляр класса
197
- `AbilityMatch`, при помощи методов которого можно определить имеется ли совпадение правила и переданных значений.
135
+ - **effect** `permit` или `deny`
136
+ - **permission** — строка вида `permission.foo.bar`, где суффикс `permission.` обязателен.
137
+ - **if all:** все группы должны быть истинны
138
+ - **if any:** — хотя бы одна группа должна быть истинна
198
139
 
199
- ```ts
200
- import { AbilityRule } from '@via-profit/ability';
201
-
202
- const rule = AbilityRule.parse({
203
- "id": "<rule-id>",
204
- "name": "Пользователь из отдела managers",
205
- "subject": "user.department",
206
- "resource": "managers",
207
- "condition": "="
208
- });
140
+ Политика может содержать одну или несколько групп правил.
209
141
 
210
- const match = rule.check({
211
- user: {
212
- department: 'managers',
213
- },
214
- });
142
+ Пример:
215
143
 
216
- const is = match.isEqual(AbilityMatch.match); // true
144
+ ```dsl
145
+ permit permission.order.update if any:
146
+ all of:
147
+ user.roles contains 'admin'
148
+ user.token is not null
217
149
 
150
+ any of:
151
+ user.roles contains 'developer'
152
+ user.login is equals 'dev'
218
153
  ```
219
154
 
220
- ## Получение пояснений (AbilityExplain)
155
+ > Префикс `permission.` обязателен в DSL, но автоматически удаляется парсером. Внутри системы разрешение хранится как `order.update`.
156
+
157
+ Пример политики выше гласит - разрешение `permission.order.update` будет разрешено при выполнении одного из двух условий:
158
+ 1. user.roles содержит 'admin' **и** user.token не null
159
+ 2. user.roles содержит 'developer' **или** user.login равен 'dev'
160
+
161
+ ### Ключ разрешения (permission key)
221
162
 
222
- Для отладки или аудита может быть полезно понять, *почему* было вынесено то или иное суждение о правах доступа. Метод `resolveWithExplain()` политики возвращает детальную информацию о проверке.
163
+ Ключ разрешения записываются в `dot notation` виде, но поддерживают возможность использования wildcard шаблонов при
164
+ помощи символа `*`. Это позволяет группировать ключи, а так же переопределять политики с похожими ключами.
223
165
 
224
- ### Использование
166
+ Если под ключ подходит несколько политик, **выполняются все**. Итог определяется **последней совпавшей политикой**:
225
167
 
168
+
169
+ **Пример использования шаблонов**
170
+
171
+ | Политика (permission) | ключ | Совпадает |
172
+ |-------------------|------------------------|-----------|
173
+ | `order.*` | `order.create` | да |
174
+ | `order.*` | `order.update` | да |
175
+ | `order.*` | `user.create` | нет |
176
+ | `*.create` | `order.create` | да |
177
+ | `*.create` | `user.create` | да |
178
+ | `*.create` | `order.update` | нет |
179
+ | `user.profile.*` | `user.profile.update` | да |
180
+ | `user.profile.*` | `user.settings.update` | нет |
181
+
182
+ **Пример политики с wildcard**
226
183
  ```ts
227
- const config: AbilityPolicyConfig = {
228
- id: 'bb758c1b-1015-4894-ba25-d23156e063cf',
229
- name: 'Запрещает менять статус заявки с `не обработан` на `завершен` всем, кроме администраторам',
230
- action: 'order.status',
231
- effect: 'deny',
232
- compareMethod: 'and',
233
- ruleSet: [
234
- {
235
- id: '9cc009e5-0aa9-453a-a668-cb3f418ced92',
236
- name: 'Не администратор',
237
- compareMethod: 'and',
238
- rules: [
239
- {
240
- id: '4093cd50-e54f-4062-8053-2d3b5966fad3',
241
- name: 'Нет роли администраторов',
242
- subject: 'user.roles',
243
- resource: 'administrator',
244
- condition: 'not in',
245
- },
246
- ],
247
- },
248
- {
249
- id: '2f8f9d71-860b-4fa6-b395-9331f1f0848e',
250
- name: 'Проверка статуса `не обработан` -> `завершен`',
251
- compareMethod: 'and',
252
- rules: [
253
- {
254
- id: 'a3c7d66f-5c2d-4a24-83bc-03b0a2d9c32b',
255
- name: 'Текущий статус `не обработан`',
256
- subject: 'order.status',
257
- resource: 'не обработан',
258
- condition: '=',
259
- },
260
- {
261
- id: 'a3c7d66f-5c2d-4a24-83bc-03b0a2d9c32b',
262
- name: 'Будущий статус `завершен`',
263
- subject: 'feature.status',
264
- resource: 'завершен',
265
- condition: '=',
266
- },
267
- ],
268
- },
269
- ],
270
- };
184
+ import { AbilityDSLParser, AbilityResolver } from '@via-profit/ability';
271
185
 
272
- const policy = AbilityPolicy.parse<Resources>(config);
273
- const resolver = new AbilityResolver(policy);
274
- const explain = resolver.resolveWithExplain('order.status', {
275
- user: {
276
- roles: ['user', 'couch'],
277
- },
278
- order: {
279
- status: 'не обработан',
280
- },
281
- feature: {
282
- status: 'завершен',
283
- },
284
- });
186
+ // DSL не полный и показан только ради примера
187
+ const dsl = `
188
+ permit permission.order.*
189
+ deny permission.order.update
190
+ `;
285
191
 
286
- explain.forEach((e) => {
287
- console.debug(e.toString());
288
- });
192
+ const policies = new AbilityDSLParser(dsl).parse();
193
+ const resolver = new AbilityResolver(policies);
289
194
 
290
- type Resources = {
291
- ['order.status']: {
292
- readonly user: {
293
- readonly roles: readonly string[];
294
- };
295
- readonly order: {
296
- readonly status: string;
297
- };
298
- readonly feature: {
299
- readonly status: string;
300
- };
301
- };
302
- };
195
+ await resolver.enforce('order.update', resource); // выбросит AbilityError
303
196
 
304
197
  ```
305
198
 
306
- Результат `console.debug`
199
+ **Пояснение**
200
+
201
+ В DSL порядок политик имеет значение:
202
+ последняя совпавшая политика выигрывает.
203
+
204
+ Поэтому:
205
+
206
+ 1. `permit` `permission.order.*` разрешает всё, что начинается с `order.`
207
+ 2. `deny` `permission.order.update` перекрывает это разрешение.
208
+
209
+ Итог выполнения:
307
210
 
308
211
  ```
309
- policy «Запрещает менять статус заявки...» is match
310
- ruleSet «Не администратор» is match
311
- rule «Нет роли администраторов» is match
312
- ruleSet «Проверка статуса...» is match
313
- ✓ rule «Текущий статус "не обработан"» is match
314
- ✓ rule «Будущий статус "завершен"» is match
212
+ order.update deny
213
+ order.create permit
214
+ order.delete permit
215
+ order.view → permit
315
216
  ```
316
217
 
317
- ---
318
218
 
319
- ## Группы правил
219
+ ### Комментарии
320
220
 
321
- **Группы правил** необходимы для объединения нескольких правил в группу. **Основная цель** - выполнить проверку каждого
322
- правила в группе и вернуть лишь один результат.
221
+ Строки, начинающиеся с символа `#` считаются комментариями и не влияют на результат работы правил и политик.
323
222
 
324
- Создавая группу следует указывать метод сравнения (`compareMethod`), который необходим для вычисления значения всей
325
- группы при проверке правил.
223
+ ---
326
224
 
327
- При создании необходимо указать следующие параметры:
225
+ ### Аннотации
328
226
 
329
- - **id** - `string` Уникальный идентификатор.
330
- - **name** - `string` Название группы.
331
- - **compareMethod** - `AbilityCompare` Способ сравнения правил в группе (`or` или `and`).
227
+ В настоящий момент поддерживается только одна аннотация ’name’, которая будет использована в качестве имени для политики, либо группы правил, либо правила.
332
228
 
333
- _Влияние **compareMethod** на результат вычисления группы:_
229
+ Аннотации задаются через комментарии:
334
230
 
335
- - **`or`** - Результат всей группы примет значение `match`, если хотя бы одно из правил вернуло `match`.
336
- - **`and`** - Результат всей группы примет значение `match`, если все правила вернули `match`.
231
+ ```
232
+ # @name <имя>
233
+ ```
337
234
 
338
- ### Создание группы правил
235
+ Аннотации применяются к **следующей сущности**:
339
236
 
340
- Создать группу правил можно двумя способами: создание через конструктор класса и парсинг JSON-конфига группы.
237
+ - политике
238
+ - группе
239
+ - правилу
341
240
 
342
- _Создание группы через конструктор класса_:
241
+ Пример:
343
242
 
344
- ```ts
345
- import { AbilityRuleSet, AbilityCompare } from '@via-profit/ability';
243
+ ```dsl
244
+ # @name can order update
245
+ permit permission.order.update if any:
246
+ # @name authorized admin
247
+ all of:
248
+ # @name contains role admin
249
+ user.roles contains 'admin'
250
+ ```
346
251
 
347
- const ruleSet = new AbilityRuleSet({
348
- id: '<set-id>',
349
- name: 'Название группы',
350
- compareMethod: AbilityCompare.and,
351
- });
252
+ ---
352
253
 
353
- // Добавление правил в группу
354
- ruleSet.addRules([
355
- new AbilityRule(...),
356
- new AbilityRule(...),
357
- ]);
254
+ ### Группы правил
358
255
 
256
+ Группа определяет, как объединяются правила внутри неё:
359
257
 
360
- // Сокращённая запись
361
- const ruleSet2 = AbilityRuleSet.and([
362
- new AbilityRule(...),
363
- new AbilityRule(...),
364
- ]);
258
+ ```
259
+ all of:
260
+ <rule>
261
+ <rule>
365
262
 
263
+ any of:
264
+ <rule>
265
+ <rule>
366
266
  ```
367
267
 
368
- _Создание группы через парсинг JSON-конфига группы_:
268
+ - `all of:` логическое AND
269
+ - `any of:` — логическое OR
369
270
 
370
- ```ts
371
- import { AbilityRuleSet } from '@via-profit/ability';
372
-
373
- const ruleSet = AbilityRuleSet.parse({
374
- 'id': '<set-id>',
375
- 'name': 'Название группы',
376
- 'compareMethod': 'and',
377
- 'rules': [
378
- {
379
- 'id': '<rule-id>',
380
- 'name': 'Пользователь из отдела managers',
381
- 'subject': 'user.department',
382
- 'resource': 'managers',
383
- 'condition': '=',
384
- },
385
- ],
386
- });
271
+ `all of` - значит, что группа считается выполненной, если все правила внутри группы сработали.
272
+
273
+ `any of` - значит, что группа считается выполненной, если хотя бы одно правило внутри группы сработало.
387
274
 
275
+ Каждая группа внутри политики будет вычисляться независимо от других групп. Итоговая оценка результата будет определена путем сравнения результата вычисления всех групп в политике.
276
+
277
+
278
+ Группы могут иметь аннотации:
279
+
280
+ ```dsl
281
+ # @name developer group
282
+ any of:
283
+ user.roles contains 'developer'
388
284
  ```
389
285
 
390
- ### Проверка группы правил
286
+ ---
391
287
 
392
- Для проверки группы правил следует вызвать метод `check` класса `AbilityRuleSet` передав объект проверяемого ресурса.
393
- Этот метод вернёт экземпляр класса `AbilityMatch`, при помощи методов которого можно определить имеется ли совпадение
394
- для группы и переданных значений.
288
+ ### Правила
395
289
 
396
- ```ts
397
- import { AbilityRuleSet, AbilityCompare } from '@via-profit/ability';
290
+ Правило — это атомарное условие внутри политики. Оно определяет, при каких данных политика будет считаться совпавшей. С помощью правил задаются условия по которым определяется эффективность политики (`permit` или `deny`)
398
291
 
399
- const ruleSet = new AbilityRuleSet({
400
- id: '<set-id>',
401
- name: 'Название группы',
402
- compareMethod: AbilityCompare.and,
403
- }).addRules([
404
- new AbilityRule(...),
405
- new AbilityRule(...),
406
- ]);
292
+ Правило имеет форму:
407
293
 
408
- const match = rule.check({ ... });
294
+ ```
295
+ <subject> <operator> <value?> — значение указывается не для всех операторов (например, is null не требует значения).
296
+ ```
297
+
298
+ #### Subject (субъект)
409
299
 
410
- const is = match.isEqual(AbilityMatch.match);
300
+ Идентификатор в dot‑нотации:
301
+
302
+ ```
303
+ user.roles
304
+ env.time.hour
305
+ order.total
411
306
  ```
412
307
 
413
- ___
308
+ #### Operators (операторы)
414
309
 
415
- ## Политики
310
+ _Синонимы — это альтернативные формы записи, которые также поддерживаются парсером._
416
311
 
417
- **Политики** включают в себя группы правил. Основная цель - выполнить проверку всех вложенных групп, сравнить результат
418
- выполнения групп и вернуть один единственный результат.
312
+ **Базовые операторы сравнения**
419
313
 
420
- ### Создание политики
314
+ | Оператор DSL | Синонимы | Пример | Описание | Типы |
315
+ |--------------|----------|--------|----------|------|
316
+ | **is equals** | `=`, `==`, `equals` | `age is equals 18` | Строгое равенство | number, string, boolean |
317
+ | **is not equals** | `!=`, `<>`, `not equals` | `role is not equals 'admin'` | Строгое неравенство | number, string, boolean |
318
+ | **greater than** | `>`, `gt` | `age greater than 18` | Больше | number, date |
319
+ | **greater than or equal** | `>=`, `gte` | `age greater than or equal 18` | Больше или равно | number, date |
320
+ | **less than** | `<`, `lt` | `age less than 18` | Меньше | number, date |
321
+ | **less than or equal** | `<=`, `lte` | `age less than or equal 18` | Меньше или равно | number, date |
421
322
 
422
- Создать политику можно двумя способами: создание через конструктор класса и парсинг JSON-конфига политики.
423
323
 
424
- При создании политики необходимо указать следующие параметры:
324
+ **Null‑операторы**
425
325
 
426
- - **id** - `string` Уникальный идентификатор.
427
- - **name** - `string` Название политики.
428
- - **action** - `string` Ключ политики, в формате Dot notation, определяющий схожесть политик. В названии может
429
- применяться символ звездочки (`*`). Политики с одинаковым экшеном обрабатываются вместе как группа политик. Экшен
430
- `users.account` не считается похожим с экшеном `users.account.login`, но в это же время `users.account.*` равен экшену
431
- `users.account.login` (из-за использования звездочки).
432
- - **compareMethod** - `AbilityCompare` Метод сравнения групп правил, входящих в политику (`or` или `and`)
433
- - **effect** - `AbilityPolicyEffect` Определяет итоговый результат всех вычислений (`permit` или `deny`). В слчае
434
- использования класса `AbilityResolver` (метод `enforce`) последний выкинет исключение `AbilityError`, если политика
435
- вернёт `deny`. Текст сообщения `AbilityError` будет соответствовать названию сработавшей политики. В остальных случаях
436
- ничего не произойдет.
437
- - **ruleSet** - `AbilityRuleSet[]` Массив групп (см. [Группы правил](#группы-правил))
326
+ | Оператор DSL | Синонимы | Пример | Описание | Типы |
327
+ |--------------|----------|--------|----------|------|
328
+ | **is null** | `== null`, `= null` | `middleName is null` | Значение отсутствует | any |
329
+ | **is not null** | `!= null` | `middleName is not null` | Значение присутствует | any |
438
330
 
439
- **Замечание** - Политика может быть запрещающей (`effect` = `deny`) и разрешающей (`effect` = `permit`). Если вам
440
- необходимо ограничить какой-либо доступ, например, пользователю с недостаточными правами, то следует создавать политику
441
- с эффектом `deny`.
331
+ **Операторы для списков (массивов)**
442
332
 
443
- _Создание политики через конструктор класса_:
333
+ | Оператор DSL | Синонимы | Пример | Описание | Типы |
334
+ |--------------|---------------------------|--------|----------|------|
335
+ | **in [...]** | - | `role in ['admin', 'manager']` | Значение входит в список | number, string |
336
+ | **not in [...]** | - | `role not in ['banned']` | Значение не входит | number, string |
337
+ | **contains** | `includes`, `has` | `tags contains 'vip'` | Массив содержит элемент | array |
338
+ | **not contains** | `not includes`, `not has` | `tags not contains 'vip'` | Массив не содержит элемент | array |
444
339
 
445
- ```ts
446
- import { AbilityPolicy, AbilityCondition } from '@via-profit/ability';
447
-
448
- const policy = new AbilityPolicy({
449
- id: '<policy-id>',
450
- name: 'Пример политики',
451
- effect: 'deny',
452
- action: 'users.update',
453
- compareMethod: 'and',
454
- ruleSet: [
455
- new AbilityRule({
456
- id: '<rule-id>',
457
- name: 'Пользователь является владельцем заказа',
458
- subject: 'user.id',
459
- resource: 'order.owner',
460
- condition: AbilityCondition.equal
461
- })
462
- ]
463
- });
464
340
 
465
- ```
341
+ **Строковые операторы**
466
342
 
467
- _Создание политики через парсинг JSON-конфига_:
343
+ | Оператор DSL | Синонимы | Пример | Описание | Типы |
344
+ |--------------|----------|--------|----------|------|
345
+ | **starts with** | `begins with` | `email starts with 'admin@'` | Строка начинается с | string |
346
+ | **not starts with** | — | `email not starts with 'test'` | Строка не начинается с | string |
347
+ | **ends with** | — | `email ends with '.ru'` | Строка заканчивается на | string |
348
+ | **not ends with** | — | `email not ends with '.com'` | Строка не заканчивается на | string |
349
+ | **includes** | `contains substring` | `name includes 'lex'` | Строка содержит подстроку | string |
350
+ | **not includes** | — | `name not includes 'test'` | Строка не содержит подстроку | string |
468
351
 
469
- ```ts
470
- import { AbilityPolicy } from '@via-profit/ability';
471
-
472
- const policy = AbilityPolicy.parse({
473
- "id": "bb758c1b-1015-4894-ba25-d23156e063cf",
474
- "name": "Status hui",
475
- "action": "order.status",
476
- "effect": "deny",
477
- "compareMethod": "and",
478
- "ruleSet": [
479
- {
480
- "id": "9cc009e5-0aa9-453a-a668-cb3f418ced92",
481
- "name": "Не администратор",
482
- "compareMethod": "and",
483
- "rules": [
484
- {
485
- "id": "4093cd50-e54f-4062-8053-2d3b5966fad3",
486
- "name": "Нет роли администраторв",
487
- "subject": "account.roles",
488
- "resource": "administrator",
489
- "condition": "<>"
490
- }
491
- ]
492
- }
493
- ]
494
- });
352
+ **Булевые операторы**
353
+
354
+ | Оператор DSL | Синонимы | Пример | Описание | Типы |
355
+ |--------------|----------|--------|----------|------|
356
+ | **is true** | `= true` | `isActive is true` | Значение истинно | boolean |
357
+ | **is false** | `= false` | `isActive is false` | Значение ложно | boolean |
358
+
359
+ **Операторы длины**
360
+
361
+ | Оператор DSL | Синонимы | Пример | Описание | Типы |
362
+ |--------------|----------|--------|----------|------|
363
+ | **length equals** | `len =` | `tags length equals 3` | Длина равна | array, string |
364
+ | **length greater than** | `len >` | `tags length greater than 2` | Длина больше | array, string |
365
+ | **length less than** | `len <` | `tags length less than 5` | Длина меньше | array, string |
366
+
367
+ #### Value (значение)
495
368
 
369
+ Поддерживаются:
370
+
371
+ - строки `'text'`
372
+ - числа `42`
373
+ - булевы `true` / `false`
374
+ - `null`
375
+ - массивы `[1, 2, 3]` / `['foo', false, null, 1, 2, '999']`
376
+
377
+ Примеры:
378
+
379
+ ```dsl
380
+ # возраст пользователя больше 18
381
+ user.age greater than 18
382
+
383
+ # массив ролей содержит роль 'admin'
384
+ user.roles contains 'admin'
385
+
386
+ # тэг заказа либо 'vip', либо 'priority'
387
+ order.tag in ['vip', 'priority']
388
+
389
+ # токен пользователя не null
390
+ user.token is not null
391
+
392
+ # логин пользователя длиннее 12 символов
393
+ user.login length greater than 12
496
394
  ```
497
395
 
498
- ### Проверка политики
499
396
 
500
- Для проверки политики правил следует вызвать метод `check` класса `AbilityPolicy` передав объект проверяемого ресурса.
501
- Этот метод вернёт экземпляр класса `AbilityMatch`, при помощи методов которого можно определить имеется ли совпадение
502
- для группы и переданных значений.
503
397
 
504
- ```ts
505
- import { AbilityPolicy } from '@via-profit/ability';
398
+ ---
506
399
 
507
- const policy = AbilityPolicy.parse({ ... });
400
+ ### Неявная группа (implicit group)
508
401
 
509
- const match = policy.check({ ... });
402
+ Если правила идут без `all of:` или `any of:`, они объединяются оператором политики:
510
403
 
511
- const is = match.isEqual(AbilityMatch.match);
404
+ ```dsl
405
+ permit permission.order.update if all:
406
+ user.roles contains 'admin'
407
+ user.token is not null
512
408
  ```
513
409
 
514
- ___
410
+ Эквивалентно:
515
411
 
516
- ## Управление политиками
412
+ ```dsl
413
+ permit permission.order.update if all:
414
+ all of:
415
+ user.roles contains 'admin'
416
+ user.token is not null
417
+ ```
517
418
 
518
- Класс `AbilityResolver` это основной инструмент для применения политик в рантайме. Он решает две ключевые задачи:
419
+ Неявная группа всегда соответствует оператору политики (`if all` или `if any`).
519
420
 
520
- 1. **Фильтрация политик по действию** — выбирает только те политики, которые применимы к выполняемой операции
521
- 2. **Оценка разрешений** — последовательно проверяет отобранные политики и возвращает итоговый результат (разрешено/запрещено)
421
+ ---
522
422
 
523
- ### Зачем нужен AbilityResolver?
423
+ ### Полный пример
524
424
 
525
- Представим, что в системе есть десятки политик, каждая на своё действие:
526
- - `order.create` правила создания заказа
527
- - `order.update` правила обновления заказа
528
- - `order.delete` — правила удаления заказа
529
- - `user.profile.update` — правила обновления профиля
530
- - и так далее...
425
+ ```dsl
426
+ # @name разрешено обновление заказа
427
+ permit permission.order.update if any:
531
428
 
532
- Когда пользователь пытается создать заказ, нам нужно проверить только политики, связанные с действием `order.create`, игнорируя все остальные. Именно это и делает `AbilityResolver`.
429
+ # @name если это администратор
430
+ all of:
431
+ user.roles contains 'admin'
432
+ user.token is not null
533
433
 
534
- ### Использование wildcard (*) в действиях
434
+ # @name если это разработчик
435
+ any of:
436
+ user.roles contains 'developer'
437
+ user.login is equals 'dev'
438
+ ```
535
439
 
536
- `AbilityResolver` поддерживает использование символа звездочки (`*`) в названиях действий. Это позволяет создавать политики, которые применяются к целым группам операций.
537
440
 
538
- #### Правила сопоставления с wildcard:
539
441
 
540
- | Политика (action) | Проверяемое действие | Результат |
541
- |-------------------|---------------------|-----------|
542
- | `order.*` | `order.create` | ✅ Совпадает |
543
- | `order.*` | `order.update` | ✅ Совпадает |
544
- | `order.*` | `order.delete` | ✅ Совпадает |
545
- | `order.*` | `user.create` | ❌ Не совпадает |
546
- | `*.create` | `order.create` | ✅ Совпадает |
547
- | `*.create` | `user.create` | ✅ Совпадает |
548
- | `*.create` | `order.update` | ❌ Не совпадает |
549
- | `user.profile.*` | `user.profile.update` | ✅ Совпадает |
550
- | `user.profile.*` | `user.profile.delete` | ✅ Совпадает |
551
- | `user.profile.*` | `user.settings.update` | ❌ Не совпадает |
442
+ ## Объединение политик
552
443
 
553
- #### Примеры использования wildcard:
444
+ В реальном проекте следует использовать несколько политик сразу
554
445
 
555
- ```ts
556
- // Политика, применяемая ко всем действиям с заказами
557
- {
558
- id: 'orders-audit',
559
- name: 'Audit all order operations',
560
- action: 'order.*', // Применится к order.create, order.update, order.delete и т.д.
561
- effect: 'permit',
562
- // ... правила
563
- }
446
+ TODO: использование нескольких политик
564
447
 
565
- // Политика, применяемая ко всем операциям создания
566
- {
567
- id: 'create-audit',
568
- name: 'Audit all create operations',
569
- action: '*.create', // Применится к order.create, user.create, product.create и т.д.
570
- effect: 'permit',
571
- // ... правила
572
- }
448
+ ## Environment политик
573
449
 
574
- // Политика для всех операций в модуле пользователей
575
- {
576
- id: 'user-module',
577
- name: 'User module base policy',
578
- action: 'user.*.*', // Применится к user.profile.update, user.settings.delete и т.д.
579
- effect: 'deny',
580
- // ... правила
581
- }
582
- ```
450
+ **Environment** это объект, содержащий данные окружения, которые не принадлежат ни пользователю, ни ресурсу.
451
+ Содержимое объекта определяется разработчиком и может быть любым объектом состоящим из примитивов.
583
452
 
584
- #### Приоритет и множественное совпадение
453
+ - время запроса,
454
+ - IP‑адрес,
455
+ - параметры устройства,
456
+ - заголовки запроса,
457
+ - контекст сессии,
458
+ - любые другие внешние условия.
585
459
 
586
- Если несколько политик подходят под проверяемое действие, будут применены **все** подходящие политики. Результат определяется последней сработавшей политикой:
460
+ **Примеры:**
587
461
 
588
462
  ```ts
589
- const policies = [
590
- AbilityPolicy.parse({
591
- action: 'order.*',
592
- effect: 'permit',
593
- // ... правила
594
- }),
595
- AbilityPolicy.parse({
596
- action: 'order.update',
597
- effect: 'deny',
598
- // ... правила
599
- })
600
- ];
463
+ type Environment = {
464
+ time: {
465
+ hour: number;
466
+ };
467
+ ip: string;
468
+ geo: {
469
+ country: string;
470
+ };
471
+ };
472
+ ```
601
473
 
602
- const resolver = new AbilityResolver(policies);
474
+ Environment передаётся в `resolve()` и `enforce()` как третий аргумент:
603
475
 
604
- // При проверке order.update сработают ОБЕ политики
605
- // Результат будет deny, так как это эффект последней сработавшей политики,
606
- // таким образом, каждая последующая политика считается важнее предыдущей.
607
- // Это применительно для ситуаций, когда необходимо, что называется, наложить вето
608
- // на принятые ранее решения вышестоящих политик
609
- resolver.enforce('order.update', data);
476
+ ```ts
477
+ await resolver.resolve('order.update', resource, environment);
478
+ await resolver.enforce('order.update', resource, environment);
610
479
  ```
611
480
 
612
- **Каждая последующая политика считается важнее предыдущей.
613
- Это применительно для ситуаций, когда необходимо, что называется, наложить вето
614
- на принятые ранее решения вышестоящих политик**
481
+ ### Использование environment в правилах
615
482
 
616
- #### Комбинирование точных действий и wildcard
483
+ В политике можно ссылаться на environment через путь `env.*`.
617
484
 
618
- Вы можете комбинировать точные действия и wildcard для создания гибкой системы прав:
485
+ Пример политики, которая запрещает обновление заказов ночью (22:00–06:00).:
619
486
 
620
- ```ts
621
- const policies = [
622
- // Общее правило для всех заказов
623
- {
624
- action: 'order.*',
625
- effect: 'deny', // По умолчанию запрещено
626
- // ...
627
- },
628
- // Исключение для создания заказа
629
- {
630
- action: 'order.create',
631
- effect: 'permit', // Создавать можно
632
- // ...
633
- },
634
- // Дополнительная проверка для обновления
635
- {
636
- action: 'order.update',
637
- effect: 'deny', // Обновление требует особых условий
638
- ruleSet: [
639
- // ... сложные правила для обновления
640
- ]
641
- }
642
- ];
487
+ ```dsl
488
+ # @name Deny updates at night
489
+ deny permission.order.update if all:
490
+ env.time.hour less than 6
491
+ env.time.hour greater or equal than 22
643
492
  ```
644
493
 
645
- ### Как это работает
494
+ **Извлечение значений из environment**
646
495
 
647
- ```ts
648
- import { AbilityPolicy, AbilityResolver } from '@via-profit/ability';
649
- import type { AbilityPolicyConfig } from '@via-profit/ability';
650
-
651
- // Загружаем все политики системы (например, из JSON-файлов)
652
- const configs: AbilityPolicyConfig[] = [
653
- {
654
- id: 'order-create-policy',
655
- name: 'Политика создания заказа',
656
- action: 'order.create',
657
- effect: 'permit',
658
- compareMethod: 'and',
659
- ruleSet: [
660
- // ... правила для создания заказа
661
- ]
662
- },
663
- {
664
- id: 'order-update-policy',
665
- name: 'Политика обновления заказа',
666
- action: 'order.update',
667
- effect: 'deny',
668
- compareMethod: 'and',
669
- ruleSet: [
670
- // ... правила для обновления заказа
671
- ]
672
- },
673
- {
674
- id: 'orders-base-policy',
675
- name: 'Базовая политика для всех операций с заказами',
676
- action: 'order.*',
677
- effect: 'deny',
678
- compareMethod: 'and',
679
- ruleSet: [
680
- // ... общие правила для всех заказов
681
- ]
682
- }
683
- ];
496
+ Если в правиле указан путь:
684
497
 
685
- // Создаем экземпляры политик
686
- const policies = AbilityPolicy.parseAll(configs);
498
+ - `env.*` значение берётся из environment
499
+ - `user.*`, `order.*`, `profile.*` → из resource
500
+ - литерал (`18`, `"admin"`, `true`) → используется как есть
687
501
 
688
- // Создаем резолвер со всеми политиками
689
- const resolver = new AbilityResolver(policies);
502
+ Пример:
690
503
 
691
- // При выполнении действия указываем только нужный action
692
- // AbilityResolver автоматически отфильтрует политики и проверит их
693
- resolver.enforce('order.create', {
694
- user: {
695
- department: 'managers',
696
- roles: ['manager']
697
- },
698
- order: {
699
- amount: 5000
700
- }
701
- });
504
+ ```ts
505
+ subject: "env.geo.country"
506
+ resource: "user.country"
507
+ condition: "equal"
702
508
  ```
703
509
 
704
- ### Проверка соответствия действия
510
+ ### Environment в TypeScript
705
511
 
706
- Метод `isInActionContain` позволяет проверить, соответствует ли действие шаблону с wildcard:
512
+ Тип Environment задаётся на уровне `AbilityResolver`:
707
513
 
708
514
  ```ts
709
- // Статический метод класса AbilityResolver
710
- AbilityResolver.isInActionContain('order.*', 'order.create'); // true
711
- AbilityResolver.isInActionContain('order.*', 'user.create'); // false
712
- AbilityResolver.isInActionContain('*.update', 'order.update'); // true
713
- AbilityResolver.isInActionContain('*.update', 'order.create'); // false
515
+ const resolver = new AbilityResolver<Resources, Environment>(policies);
714
516
  ```
715
517
 
716
- Этот метод используется внутри `AbilityResolver` для фильтрации политик, но может быть полезен и в пользовательском коде.
518
+ Это позволяет:
717
519
 
718
- ### Методы AbilityResolver
520
+ - получать автодополнение в IDE,
521
+ - проверять корректность путей `env.*`,
522
+ - избегать ошибок при передаче environment.
719
523
 
720
- #### `enforce()` строгая проверка
524
+ > Если правило использует `env.*`, но environment не передан, то значение `env.*` будет `undefined`, и сравнение будет выполнено так, как если бы environment не было вовсе
721
525
 
722
- ```typescript
723
- try {
724
- resolver.enforce('order.update', data);
725
- // Доступ разрешен - продолжаем выполнение
726
- await updateOrder(data);
727
- } catch (error) {
728
- if (error instanceof AbilityError) {
729
- // Доступ запрещен - error.message содержит название сработавшей политики
730
- console.error(`Доступ запрещен политикой: ${error.message}`);
731
- return;
732
- }
733
- throw error;
734
- }
735
- ```
736
526
 
737
- **Важно**: `enforce()` выбрасывает исключение, если хотя бы одна подходящая политика вернула `deny`. Если ни одна политика не сработала или все вернули `permit` — исключения не будет.
738
527
 
739
- #### `resolve()` мягкая проверка
528
+ ## Генератор типов для TypeScript
740
529
 
741
- ```typescript
742
- const result = resolver
743
- .resolve('order.update', data)
744
- .isDeny(); // true если доступ запрещен
530
+ `AbilityParser.generateTypeDefs()` генерирует типы для TypeScript на основе политик, что позволяет не беспокоиться о расхождении между типами и данными в политиках.
745
531
 
746
- if (result) {
747
- // Самостоятельно обрабатываем запрет
748
- return { error: 'Access denied' };
749
- }
532
+ **Пример использования**
750
533
 
751
- // Продолжаем выполнение
752
- await updateOrder(data);
753
- ```
534
+ Сначала необходимо подготовить массив политик. Политики можно хранить в DSL или в JSON и парсить их в массив готовых политик. В данном примере, для наглядности, политики хранятся в DSL.
754
535
 
755
- #### `resolveWithExplain()` — проверка с объяснением
536
+ ```ts
537
+ // scripts/policies.ts
756
538
 
757
- ```typescript
758
- const explanations = resolver.resolveWithExplain('order.update', data);
539
+ import { AbilityDSLParser } from './AbilityDSLParser';
759
540
 
760
- explanations.forEach(explain => {
761
- console.log(explain.toString());
762
- // policy «Политика обновления заказа» is match
763
- // ✗ ruleSet «Проверка владельца» is mismatch
764
- // ✓ ruleSet «Проверка статуса» is match
765
- });
541
+ const dsl = `
542
+ # @name Update order
543
+ permit permission.order.update if all:
766
544
 
767
- if (resolver.isDeny()) {
768
- console.log('Доступ запрещен по причине:');
769
- explanations.forEach(e => console.log(e.toString()));
770
- }
545
+ # @name Owner check
546
+ all of:
547
+ # @name User is owner
548
+ user.id = order.ownerId
549
+ `;
550
+
551
+ const policies = new AbilityDSLParser(dsl).parse();
552
+
553
+ export default policies;
771
554
  ```
772
555
 
773
- ### Интеграция с TypeScript
556
+ ```ts
557
+ // scripts/generate-types.ts
558
+ import { writeFileSync } from 'node:fs';
559
+ import { AbilityParser } from '@via-profit/ability';
560
+ import policies from './policies.json';
774
561
 
775
- Для полной типобезопасности определите интерфейс `Resources`, где ключи — это возможные действия, а значения — структура данных, требуемая для проверки:
562
+ const typedefs = AbilityParser.generateTypeDefs(policies);
776
563
 
777
- ```typescript
778
- // Определяем типы данных для каждого действия
779
- type Resources = {
780
- 'order.create': {
781
- readonly user: {
782
- readonly department: string;
783
- readonly roles: readonly string[];
784
- };
785
- readonly order: {
786
- readonly amount: number;
787
- };
788
- };
789
-
564
+ writeFileSync('./src/ability/types.generated.ts', typedefs, 'utf8');
565
+ ```
566
+
567
+ **Сгенерированный файл (пример)**
568
+
569
+ ```ts
570
+ // src/ability/types.generated.ts
571
+
572
+ // Automatically generated by via-profit/ability
573
+ // Do not edit manually
574
+ export type Resources = {
790
575
  'order.update': {
791
576
  readonly user: {
792
577
  readonly id: string;
793
578
  };
794
579
  readonly order: {
795
- readonly id: string;
796
- readonly status: string;
797
580
  readonly ownerId: string;
798
581
  };
799
582
  };
800
-
801
- 'user.profile.update': {
802
- readonly user: {
803
- readonly id: string;
804
- };
805
- readonly profile: {
806
- readonly userId: string;
807
- };
808
- };
809
583
  };
584
+ ```
810
585
 
811
- // Теперь TypeScript будет проверять корректность передаваемых данных
812
- resolver.enforce('order.create', {
813
- user: {
814
- department: 'managers',
815
- roles: ['manager']
816
- },
817
- order: {
818
- amount: 5000 // ✅ все поля на месте
819
- }
586
+ **Использование в коде**
587
+
588
+ ```ts
589
+ import { AbilityResolver, AbilityPolicy } from '@via-profit/ability';
590
+ import type { Resources } from './ability/types.generated';
591
+
592
+ const resolver = new AbilityResolver<Resources>(
593
+ AbilityPolicy.parseAll(policies),
594
+ );
595
+
596
+ await resolver.enforce('order.update', {
597
+ user: { id: 'u1' },
598
+ order: { ownerId: 'u1' },
820
599
  });
600
+ ```
821
601
 
822
- // Ошибка компиляции - не хватает required полей
823
- resolver.enforce('order.create', {
824
- user: {
825
- department: 'managers'
826
- // missing roles
827
- }
602
+ ## Отладка политик
603
+
604
+ ### Объяснения
605
+
606
+ Для упрощения отладки политик применяется специальный класс `AbilityResult`, который уже включён в итоговый результат вычислений. `AbilityResult` инкапсулирует итог применения всех подходящих политик к ключу разрешений и ресурсу.
607
+
608
+ `AbilityResult` содержит:
609
+
610
+ - список проверенных политик,
611
+ - методы для определения итогового эффекта,
612
+ - методы для получения объяснений в текстовом представлении.
613
+
614
+ Пример:
615
+
616
+ ```ts
617
+ const result = await resolver.resolve('order.update', resource);
618
+
619
+ if (result.isDenied()) {
620
+ console.log('Access denied');
621
+ }
622
+
623
+ const explanations = result.explain(); // AbilityExplain
624
+
625
+ // console.log(explanations.toString());
626
+ ```
627
+
628
+ ### AbilityExplain
629
+
630
+ `AbilityExplain` и связанные классы (`AbilityExplainPolicy`, `AbilityExplainRuleSet`, `AbilityExplainRule`) позволяют получить человекочитаемое объяснение:
631
+
632
+ - какая политика сработала,
633
+ - какие группы правил совпали,
634
+ - какие правила не прошли,
635
+ - какой эффект был применён.
636
+
637
+ Пример использования:
638
+
639
+ ```ts
640
+ const result = await resolver.resolve('order.update', resource);
641
+ const explanations = result.explain();
642
+
643
+ console.log(explanations.toString());
644
+ ```
645
+
646
+ Пример вывода:
647
+
648
+ ```
649
+ ✓ policy «Запрет обновления заказа для менеджеров» is match
650
+ ✓ ruleSet «Менеджеры» is match
651
+ ✓ rule «Отдел managers» is match
652
+ ✗ rule «Роль manager» is mismatch
653
+ ✓ ruleSet «Не администраторы» is match
654
+ ✓ rule «Нет роли administrator» is match
655
+ ```
656
+
657
+ ### Формат вывода
658
+
659
+ В настоящий момент поддерживается только один формат вывода - текстовый.
660
+
661
+ Вывод строится по принципу: <policy | ruleSet | rule > <название> <is match | is mismatch>
662
+
663
+
664
+ ## Решение проблем
665
+
666
+ ### Модель принятия решений (Default Deny)
667
+
668
+ > Почему политика `deny` не превращается в `permit`, если её условия не выполнены?
669
+
670
+ Рассмотрим политику, которая **запрещает** доступ пользователю с возрастом 16 лет:
671
+
672
+ ```ts
673
+ const dsl = `
674
+ deny permission.test if all:
675
+ user.age is equals 16
676
+ `;
677
+
678
+ const policies = new AbilityDSLParser(dsl).parse();
679
+ const resolver = new AbilityResolver(policies);
680
+
681
+ const result = await resolver.resolve('test', {
682
+ user: { age: 16 },
828
683
  });
684
+
685
+ console.log(result.isDenied()); // true ✔
686
+ console.log(result.isAllowed()); // false ✔
829
687
  ```
830
688
 
831
- ### Как формируется итоговое решение?
689
+ В этом случае всё очевидно:
690
+ условие выполнено → политика совпала → эффект `deny` → доступ запрещён.
832
691
 
833
- 1. Resolver собирает все политики, у которых `action` соответствует запрошенному (поддерживается wildcard `*`)
834
- 2. Последовательно проверяет каждую политику методом `check()`
835
- 3. Если политика вернула `match`, запоминает её `effect` (permit/deny)
836
- 4. Возвращается `effect` **последней сработавшей политики**
692
+ **Что происходит, если условия `не выполнены`?**
837
693
 
838
- ```typescript
839
- // Пример с несколькими политиками на одно действие
840
- const policies = [
841
- permitPolicy, // permit, но не match
842
- denyPolicy, // deny и match
843
- permitPolicy2 // permit, но не match
844
- ];
694
+ ```ts
695
+ const result = await resolver.resolve('test', {
696
+ user: { age: 12 },
697
+ });
845
698
 
846
- resolver.enforce('some.action', data);
847
- // Результат: deny (от последней сработавшей политики)
699
+ console.log(result.isDenied()); // true ✔
700
+ console.log(result.isAllowed()); // false
848
701
  ```
849
702
 
850
- ### Когда использовать Resolver?
703
+ На первый взгляд может показаться, что если условие не выполнено, то политика должна «разрешить» доступ.
704
+ Но это **не так**.
851
705
 
852
- - **В API endpoints** — проверка прав перед выполнением операции
853
- - **В middleware** — централизованная проверка доступа
854
- - **В сервисах** — защита бизнес-логики
855
- - **В клиентском коде** — условный рендеринг UI на основе прав
706
+ **Модель принятия решений: `Default Deny`**
856
707
 
857
- Resolver делает систему прав предсказуемой, типобезопасной и легко расширяемой.
708
+ `AbilityResolver` использует классическую модель безопасности:
858
709
 
859
- ## API Reference
710
+ > **Если нет ни одной совпавшей permit‑политики → доступ запрещён.**
860
711
 
861
- ### Класс AbilityCode
712
+ **Что происходит в данном примере:**
862
713
 
863
- Базовый абстрактный класс для всех кодовых значений в системе.
714
+ 1. Политика `deny` существует, но её условие **не выполнено**
715
+ → политика получает статус `mismatch`.
864
716
 
865
- | Метод | Аргументы | Возвращаемое значение | Описание |
866
- |-------|-----------|----------------------|----------|
867
- | `isEqual()` | `compareWith: AbilityCode<T> \| null` | `boolean` | Сравнивает текущий код с другим экземпляром |
868
- | `isNotEqual()` | `compareWith: AbilityCode<T> \| null` | `boolean` | Проверяет неравенство с другим экземпляром |
717
+ 2. Политика `deny` **не применяется**, потому что условия не совпали.
869
718
 
870
- **Геттеры:**
871
- - `code: T` - возвращает сырое значение кода (строку или число)
719
+ 3. Политики `permit` **нет**.
872
720
 
873
- ---
721
+ 4. Раз нет ни одной разрешающей политики → итоговое решение:
722
+ **deny (по умолчанию)**.
874
723
 
875
- ### Класс AbilityMatch
876
724
 
877
- Представляет возможные состояния результата проверки правил.
725
+ **Итог**
878
726
 
879
- **Статические свойства:**
880
- - `AbilityMatch.pending` - ожидание проверки
881
- - `AbilityMatch.match` - совпадение найдено
882
- - `AbilityMatch.mismatch` - совпадение не найдено
727
+ - `deny` с совпавшими условиями → **deny**
728
+ - `deny` с несовпавшими условиями → **deny (default deny)**
729
+ - `permit` с совпавшими условиями → **allow**
730
+ - `permit` с несовпавшими условиями → **deny (default deny)**
883
731
 
884
- Каждое свойство является экземпляром класса `AbilityMatch` и наследует все его методы.
732
+ **Заключение**
885
733
 
886
- ---
734
+ **Доступ разрешается только при наличии явного permit.**
887
735
 
888
- ### Класс AbilityCondition
736
+ ## Рекомендации по проектированию
889
737
 
890
- Определяет операторы сравнения для правил доступа.
738
+ ### Именование ключей доступа
891
739
 
892
- **Статические свойства:**
893
- - `AbilityCondition.equal` - равно (`=`)
894
- - `AbilityCondition.not_equal` - не равно (`<>`)
895
- - `AbilityCondition.more_than` - больше (`>`)
896
- - `AbilityCondition.less_than` - меньше (`<`)
897
- - `AbilityCondition.less_or_equal` - меньше или равно (`<=`)
898
- - `AbilityCondition.more_or_equal` - больше или равно (`>=`)
899
- - `AbilityCondition.in` - входит в массив (`in`)
900
- - `AbilityCondition.not_in` - не входит в массив (`not in`)
740
+ - Используйте иерархические ключи: `permission.order.create`, `permission.order.update.status`, `permission.user.profile.update`.
741
+ - Группируйте по доменам: `permission.user.*`, `permission.order.*`, `permission.product.*`.
742
+ - Не смешивайте разные домены в одном ключе.
901
743
 
902
- | Метод | Аргументы | Возвращаемое значение | Описание |
903
- |-------|-----------|----------------------|----------|
904
- | `fromLiteral()` | `literal: AbilityConditionLiteralType` | `AbilityCondition` | Создает экземпляр условия из литерального имени (например, 'equal' → '=') |
744
+ ### Структура данных
905
745
 
906
- **Геттеры:**
907
- - `literal: AbilityConditionLiteralType` - возвращает литеральное имя оператора ('equal', 'not_equal' и т.д.)
746
+ - Явно описывайте `Resources` в TypeScript.
747
+ - Не передавайте «лишние» поля это усложняет понимание.
748
+ - Старайтесь, чтобы структура данных для одного `permission` была стабильной.
908
749
 
909
- ---
750
+ ### Проектирование политик
910
751
 
911
- ### Класс AbilityCompare
752
+ - Общие правила — через wildcard (`permission.order.*`).
753
+ - Специфичные ограничения — через точные действия (`permission.order.update`).
754
+ - Для запретов используйте `effect: deny`.
755
+ - Для разрешений — `effect: permit`.
912
756
 
913
- Определяет методы логического сравнения для групп правил.
757
+ ### Типичные ошибки
914
758
 
915
- **Статические свойства:**
916
- - `AbilityCompare.and` - логическое И (все правила должны совпасть)
917
- - `AbilityCompare.or` - логическое ИЛИ (достаточно одного совпадения)
759
+ - Ожидание, что отсутствие совпавших политик означает deny.
760
+ - Смешивание бизнес-логики и политик доступа.
761
+ - Слишком крупные политики с десятками правил — лучше разбивать.
918
762
 
919
- ---
763
+ ### Пример использования на фронтенде (React)
920
764
 
921
- ### Класс AbilityPolicyEffect
765
+ **Хук для проверки политик**
922
766
 
923
- Определяет эффект применения политики.
767
+ ```tsx
768
+ // hooks/use-ability.ts
769
+ import { useEffect, useState } from 'react';
770
+ import { AbilityResolver } from '@via-profit/ability';
771
+ import { Resources } from './generated-types';
924
772
 
925
- **Статические свойства:**
926
- - `AbilityPolicyEffect.deny` - запрет доступа
927
- - `AbilityPolicyEffect.permit` - разрешение доступа
773
+ export function useAbility<Permission extends keyof Resources>(
774
+ resolver: AbilityResolver<Resources>,
775
+ permission: Permission,
776
+ resource: Resources[Permission],
777
+ ) {
778
+ const [allowed, setAllowed] = useState<boolean | null>(null);
928
779
 
929
- ---
780
+ useEffect(() => {
781
+ let cancelled = false;
930
782
 
931
- ### Класс AbilityRule
932
-
933
- Представляет отдельное правило проверки доступа.
934
-
935
- **Свойства:**
936
- - `subject: string` - путь к значению субъекта в dot-нотации
937
- - `resource: string | number | boolean | (string | number)[]` - значение или путь для сравнения
938
- - `condition: AbilityCondition` - условие сравнения
939
- - `name: string` - название правила
940
- - `id: string` - уникальный идентификатор
941
- - `state: AbilityMatch` - текущее состояние после вызова `check()`
942
-
943
- | Метод | Аргументы | Возвращаемое значение | Описание |
944
- |-------|-----------|----------------------|----------|
945
- | `check()` | `resource: Resources \| null` | `AbilityMatch` | Проверяет правило на переданных данных, обновляет `state` и возвращает результат |
946
- | `extractValues()` | `resourceData: Resources \| null` | `[any, any]` | Извлекает значения для сравнения из субъекта и ресурса по указанным путям |
947
- | `getDotNotationValue()` | `resource: unknown, desc: string` | `T \| undefined` | Извлекает значение из объекта по dot-нотации (поддерживает массивы: `users[0].name`) |
948
- | `export()` | — | `AbilityRuleConfig` | Экспортирует правило в конфигурационный объект для сериализации |
949
- | `parse()` | `config: AbilityRuleConfig` | `AbilityRule` | Статический метод. Создает экземпляр правила из конфигурационного объекта |
950
-
951
- **Статические фабричные методы (все возвращают `AbilityRule`):**
952
-
953
- | Метод | Аргументы | Описание |
954
- |-------|-----------|----------|
955
- | `equal()` | `subject: string, resource: any` | Создает правило с условием "равно" |
956
- | `notEqual()` | `subject: string, resource: any` | Создает правило с условием "не равно" |
957
- | `in()` | `subject: string, resource: any` | Создает правило с условием "входит в массив" |
958
- | `notIn()` | `subject: string, resource: any` | Создает правило с условием "не входит в массив" |
959
- | `lessThan()` | `subject: string, resource: any` | Создает правило с условием "меньше" |
960
- | `lessOrEqual()` | `subject: string, resource: any` | Создает правило с условием "меньше или равно" |
961
- | `moreThan()` | `subject: string, resource: any` | Создает правило с условием "больше" |
962
- | `moreOrEqual()` | `subject: string, resource: any` | Создает правило с условием "больше или равно" |
783
+ async function check() {
784
+ try {
785
+ const result = await resolver.resolve(permission, resource);
786
+ if (!cancelled) {
787
+ setAllowed(result.isAllowed());
788
+ }
789
+ } catch {
790
+ if (!cancelled) {
791
+ setAllowed(false);
792
+ }
793
+ }
794
+ }
963
795
 
964
- ---
796
+ check();
965
797
 
966
- ### Класс AbilityRuleSet
798
+ return () => {
799
+ cancelled = true;
800
+ };
801
+ }, [resolver, permission, resource]);
967
802
 
968
- Группирует несколько правил для совместной проверки.
803
+ return allowed;
804
+ }
805
+ ```
969
806
 
970
- **Свойства:**
971
- - `state: AbilityMatch` - текущее состояние группы после вызова `check()`
972
- - `rules: AbilityRule[]` - массив правил в группе
973
- - `compareMethod: AbilityCompare` - метод сравнения (AND/OR)
974
- - `name: string` - название группы
975
- - `id: string` - уникальный идентификатор
807
+ **Использование в компоненте**
976
808
 
977
- | Метод | Аргументы | Возвращаемое значение | Описание |
978
- |-------|-----------|----------------------|----------|
979
- | `addRule()` | `rule: AbilityRule` | `this` | Добавляет одно правило в группу (поддерживает цепочку вызовов) |
980
- | `addRules()` | `rules: AbilityRule[]` | `this` | Добавляет массив правил в группу |
981
- | `check()` | `resources: Resources \| null` | `AbilityMatch` | Проверяет все правила группы, применяет метод сравнения и возвращает результат |
982
- | `export()` | — | `AbilityRuleSetConfig` | Экспортирует группу в конфигурационный объект |
983
- | `parse()` | `config: AbilityRuleSetConfig` | `AbilityRuleSet` | Статический метод. Создает экземпляр группы из конфигурации |
984
- | `and()` | `rules: AbilityRule[]` | `AbilityRuleSet` | Статический метод. Создает группу с логическим И |
985
- | `or()` | `rules: AbilityRule[]` | `AbilityRuleSet` | Статический метод. Создает группу с логическим ИЛИ |
809
+ ```tsx
810
+ function OrderUpdateButton({ order, user }) {
811
+ const allowed = useAbility(resolver, 'order.update', {
812
+ user,
813
+ order,
814
+ });
986
815
 
987
- ---
816
+ if (allowed === null) {
817
+ return null; // или бейдж загрузки
818
+ }
988
819
 
989
- ### Класс AbilityPolicy
820
+ if (!allowed) {
821
+ return null;
822
+ }
990
823
 
991
- Объединяет группы правил в полноценную политику доступа.
824
+ return <button>Update order</button>;
825
+ }
826
+ ```
992
827
 
993
- **Свойства:**
994
- - `matchState: AbilityMatch` - состояние политики после проверки
995
- - `ruleSet: AbilityRuleSet[]` - массив групп правил
996
- - `effect: AbilityPolicyEffect` - эффект политики (permit/deny)
997
- - `compareMethod: AbilityCompare` - метод сравнения групп
998
- - `name: string` - название политики
999
- - `id: string` - уникальный идентификатор
1000
- - `action: string` - действие, к которому применяется политика
1001
828
 
1002
- | Метод | Аргументы | Возвращаемое значение | Описание |
1003
- |-------|-----------|----------------------|----------|
1004
- | `addRuleSet()` | `ruleSet: AbilityRuleSet` | `this` | Добавляет группу правил в политику |
1005
- | `check()` | `resources: Resources` | `AbilityMatch` | Проверяет все группы правил и возвращает результат |
1006
- | `explain()` | — | `AbilityExplain` | Возвращает объяснение результата проверки (должен быть вызван после `check()`) |
1007
- | `export()` | — | `AbilityPolicyConfig` | Экспортирует политику в конфигурационный объект |
1008
- | `parse()` | `config: AbilityPolicyConfig` | `AbilityPolicy` | Статический метод. Создает экземпляр политики из конфигурации |
1009
- | `parseAll()` | `configs: readonly AbilityPolicyConfig[]` | `AbilityPolicy[]` | Статический метод. Парсит массив конфигураций политик и возвращает массив экземпляров AbilityPolicy. Удобен для загрузки нескольких политик одновременно. |
829
+ ## Примеры
1010
830
 
1011
- ---
1012
831
 
1013
- ### Класс AbilityResolver
832
+ ### Пример сложной многоступенчатой политики
1014
833
 
1015
- Управляет множеством политик и их выполнением.
834
+ Ниже - многоступенчатый набор политик, на примере использования в кинотеатре (выдуманный пример).
1016
835
 
1017
- | Метод | Аргументы | Возвращаемое значение | Описание |
1018
- |-------|-----------|----------------------|----------|
1019
- | `constructor()` | `policies: AbilityPolicy[] \| AbilityPolicy` | `AbilityResolver` | Создает экземпляр с одной или несколькими политиками |
1020
- | `resolve()` | `action: keyof Resources, resource: Resources[Action]` | `this` | Фильтрует политики по действию и проверяет их, возвращает себя для цепочки вызовов |
1021
- | `resolveWithExplain()` | `action: keyof Resources, resource: Resources[Action]` | `readonly AbilityExplain[]` | Выполняет проверку и возвращает массив объяснений для каждой политики |
1022
- | `enforce()` | `action: keyof Resources, resource: Resources[Action]` | `void \| never` | Выполняет проверку и выбрасывает `AbilityError` если результат Deny |
1023
- | `getEffect()` | — | `AbilityPolicyEffect \| null` | Возвращает эффект последней сработавшей политики |
1024
- | `isPermit()` | | `boolean` | Проверяет, разрешен ли доступ |
1025
- | `isDeny()` | — | `boolean` | Проверяет, запрещен ли доступ |
1026
- | `getMatchedPolicy()` | — | `AbilityPolicy \| null` | Возвращает последнюю сработавшую политику |
1027
- | `isInActionContain()` | `actionA: string, actionB: string` | `boolean` | Статический метод. Проверяет, соответствует ли действие шаблону (поддерживает `*`) |
836
+ **Пример демонстрирует:**
837
+ - работу с ролями (admin, seller, manager, VIP, banned),
838
+ - временн́ые ограничения (`env.time.hour`),
839
+ - wildcard‑права (`permission.*`),
840
+ - ограничения по количеству билетов,
841
+ - запрет на продажу уже проданных билетов,
842
+ - комбинацию `permit`/`deny`‑политик,
843
+ - приоритет политик и модель Default Deny.
1028
844
 
1029
- ---
1030
845
 
1031
- ### Класс AbilityExplain
846
+ **Краткое описание правил**
847
+ - **Администратор**
848
+ Имеет wildcard‑права (`permission.*`) и может выполнять любые действия.
849
+ Может редактировать стоимость билетов.
1032
850
 
1033
- Представляет человекочитаемое объяснение результата проверки.
851
+ - **Продавец**
852
+ Может продавать билеты только в рабочие часы (09:00–23:00).
853
+ Не может продавать билеты, если:
854
+ - кинотеатр закрыт,
855
+ - билет уже продан.
1034
856
 
1035
- **Свойства:**
1036
- - `type: AbilityExplainType` - тип элемента ('policy' \| 'rule' \| 'ruleSet')
1037
- - `children: AbilityExplain[]` - дочерние элементы объяснения
1038
- - `name: string` - название элемента
1039
- - `match: AbilityMatch` - результат проверки
857
+ - **Менеджер**
858
+ Имеет те же права, что и продавец.
1040
859
 
1041
- | Метод | Аргументы | Возвращаемое значение | Описание |
1042
- |-------|-----------|----------------------|----------|
1043
- | `toString()` | `indent: number = 0` | `string` | Форматирует объяснение в читаемый текст с отступами |
860
+ - **Покупатели**
861
+ - Пользователь старше 21 года может покупать билеты.
862
+ - VIP‑пользователь может покупать билеты в любое время.
863
+ - Заблокированный пользователь (`status = banned`) не может покупать билеты.
864
+ - Любой пользователь не может купить более 6 билетов.
1044
865
 
1045
- **Наследники:**
1046
- - `AbilityExplainRule` - объяснение для правила
1047
- - `AbilityExplainRuleSet` - объяснение для группы правил
1048
- - `AbilityExplainPolicy` - объяснение для политики
1049
866
 
1050
- ---
867
+ **Общая диаграмма политик**
868
+
869
+ ```mermaid
870
+ flowchart LR
871
+
872
+ %% ==== ROLES ====
873
+
874
+ subgraph Roles[Роли]
875
+ A[Администратор]
876
+ B[Продавец]
877
+ C[Менеджер]
878
+ end
879
+
880
+ subgraph Buyers[Покупатели]
881
+ U1[Пользователь > 21]
882
+ U2[VIP пользователь]
883
+ U3[Заблокированный пользователь]
884
+ end
885
+
886
+ %% ==== ADMIN ====
887
+
888
+ A --> A1[Wildcard: permission.*]
889
+ A --> A2[Редактировать цену билетов]
890
+
891
+ A1 --> FINAL[Итоговое решение]
892
+ A2 --> FINAL
893
+
894
+ %% ==== SELLER ====
895
+
896
+ B --> B1[Продавать билеты]
897
+
898
+ B1 -->|09:00–23:00| B2[Разрешено]
899
+ B1 -->|Вне времени| D2[Запрещено]
900
+ B1 -->|ticket.status = sold| D3[Запрещено]
901
+
902
+ B2 --> FINAL
903
+ D2 --> FINAL
904
+ D3 --> FINAL
905
+
906
+ %% ==== MANAGER ====
907
+
908
+ C --> C1[Продавать билеты как продавец]
909
+ C1 --> FINAL
910
+
911
+ %% ==== BUYERS ====
912
+
913
+ U1 --> U1A[Покупать билеты]
914
+ U1A -->|ticketsCount < 6| U1OK[Разрешено]
915
+ U1A -->|ticketsCount ≥ 6| U1DENY[Запрещено]
916
+
917
+ U2 --> U2A[Покупать билеты в любое время]
918
+ U2A -->|ticketsCount < 6| U2OK[Разрешено]
919
+ U2A -->|ticketsCount ≥ 6| U2DENY[Запрещено]
920
+
921
+ U3 --> U3A[Запрещено покупать билеты]
922
+
923
+ U1OK --> FINAL
924
+ U1DENY --> FINAL
925
+ U2OK --> FINAL
926
+ U2DENY --> FINAL
927
+ U3A --> FINAL
928
+
929
+ %% ==== DENY RULES ====
930
+
931
+ D1[Запрещено покупать билеты, если user.status = banned] --> FINAL
1051
932
 
1052
- ### Класс AbilityParser
1053
-
1054
- Предназначен для парсинга и генерации типов из конфигураций.
1055
-
1056
- | Метод | Аргументы | Возвращаемое значение | Описание |
1057
- |-------|-----------|----------------------|----------|
1058
- | `generateTypeDefs()` | `policies: readonly AbilityPolicy[]` | `string` | Статический метод. Генерирует TypeScript тип `Resources` на основе правил во всех политиках. Анализирует условия правил и определяет соответствующие типы (string, number, boolean, массивы). Возвращает строку с готовым TypeScript определением типа. |
1059
-
1060
- #### Пример использования:
1061
-
1062
- ```typescript
1063
- import { AbilityParser, AbilityPolicy } from '@via-profit/ability';
1064
- import fs from 'node:fs';
1065
-
1066
- // Массив политик
1067
- const policies: AbilityPolicy[] = AbilityPolicy.parseAll([
1068
- {
1069
- id: 'policy-1',
1070
- name: 'Example policy',
1071
- action: 'order.status',
1072
- effect: 'deny',
1073
- compareMethod: 'and',
1074
- ruleSet: [
1075
- {
1076
- id: 'rule-set-1',
1077
- name: 'Example rule set',
1078
- compareMethod: 'and',
1079
- rules: [
1080
- {
1081
- subject: 'user.roles',
1082
- resource: ['admin'],
1083
- condition: 'in',
1084
- },
1085
- {
1086
- subject: 'order.amount',
1087
- resource: 1000,
1088
- condition: '<=',
1089
- },
1090
- ],
1091
- },
1092
- ],
1093
- },
1094
- ]);
1095
-
1096
- // Генерация TypeScript типа
1097
- const typeDefinitions = AbilityParser.generateTypeDefs(policies);
1098
-
1099
- // Запись в файл
1100
- fs.writeFileSync('./src/types/ability.ts', typeDefinitions);
1101
-
1102
- // Результат в файле:
1103
- // // Automatically generated by via-profit/ability
1104
- // // Do not edit manually
1105
- //
1106
- // export type Resources = {
1107
- // ['order.status']: {
1108
- // readonly order: {
1109
- // readonly amount: number;
1110
- // };
1111
- // readonly user: {
1112
- // readonly roles: string[];
1113
- // };
1114
- // };
1115
- // }
1116
933
  ```
1117
934
 
1118
- ---
935
+ **DSL политик**
1119
936
 
1120
- ### Классы ошибок
937
+ ```dsl
938
+ ############################################################
939
+ # @name Admin can edit ticket price
940
+ permit permission.ticket.price.edit if all:
941
+ user.role is equals 'admin'
1121
942
 
1122
- | Класс | Назначение |
1123
- |-------|------------|
1124
- | `AbilityError` | Общая ошибка доступа, выбрасывается при запрете в `enforce()` |
1125
- | `AbilityParserError` | Ошибка парсинга конфигурации или генерации типов |
1126
943
 
1127
- ## Рекомендации по использованию
944
+ ############################################################
945
+ # @name Seller can sell tickets during working hours
946
+ permit permission.ticket.sell if all:
947
+ user.role is equals 'seller'
948
+ all of:
949
+ env.time.hour greater than or equal 9
950
+ env.time.hour less than or equal 23
1128
951
 
1129
- ### Именование экшенов
1130
952
 
1131
- Используйте точечную нотацию для иерархии действий:
953
+ ############################################################
954
+ # @name Users older than 21 can buy tickets
955
+ permit permission.ticket.buy if all:
956
+ user.age greater than 21
1132
957
 
1133
- - `order.create` - создание заказа
1134
- - `order.update` - обновление заказа
1135
- - `order.status.update` - обновление статуса заказа
1136
958
 
1137
- ### Структура данных
959
+ ############################################################
960
+ # @name VIP users can buy tickets anytime
961
+ permit permission.ticket.buy if all:
962
+ user.isVIP is true
963
+
964
+
965
+ ############################################################
966
+ # @name Deny buying tickets if user is banned
967
+ deny permission.ticket.buy if all:
968
+ user.status is equals 'banned'
1138
969
 
1139
- Старайтесь группировать связанные данные под общими ключами:
970
+
971
+ ############################################################
972
+ # @name Deny selling tickets if cinema is closed
973
+ deny permission.ticket.sell if all:
974
+ any of:
975
+ env.time.hour less than 9
976
+ env.time.hour greater than 23
977
+
978
+
979
+ ############################################################
980
+ # @name Manager can do everything seller can
981
+ permit permission.ticket.sell if all:
982
+ user.role is equals 'manager'
983
+
984
+
985
+ ############################################################
986
+ # @name Admin wildcard permissions
987
+ permit permission.* if all:
988
+ user.role is equals 'admin'
989
+
990
+
991
+ ############################################################
992
+ # @name Limit tickets per user (max 6)
993
+ deny permission.ticket.buy if all:
994
+ user.ticketsCount greater than or equal 6
995
+
996
+
997
+ ############################################################
998
+ # @name Cannot sell already sold tickets
999
+ deny permission.ticket.sell if all:
1000
+ ticket.status is equals 'sold'
1001
+
1002
+ ```
1003
+
1004
+
1005
+ Ниже показано, как использовать приведённые выше политики в Node.js + TypeScript.
1006
+
1007
+ **Подготовка политик**
1140
1008
 
1141
1009
  ```ts
1142
- // Хорошо
1143
- {
1144
- user: { id: '123', roles: ['admin'] },
1145
- order: { status: 'new', amount: 1000 }
1146
- }
1010
+ import { AbilityDSLParser } from '@via-profit/ability';
1011
+ import cinemaDSL from './policies/cinema.dsl';
1012
+
1013
+ export const policies = new AbilityDSLParser(cinemaDSL).parse();
1014
+ ```
1147
1015
 
1148
- // Плохо
1149
- {
1150
- userId: '123',
1151
- userRoles: ['admin'],
1152
- orderStatus: 'new',
1153
- orderAmount: 1000
1016
+ **Создание резолвера**
1017
+
1018
+ ```ts
1019
+ import { AbilityResolver } from '@via-profit/ability';
1020
+ import { policies } from './policies';
1021
+
1022
+ const resolver = new AbilityResolver(policies);
1023
+ ```
1024
+
1025
+ **Проверка разрешений (enforce)**
1026
+
1027
+ Пример: покупка билета.
1028
+
1029
+ Метод enforce выбрасывает исключение `AbilityError`, если доступ запрещён.
1030
+
1031
+ ```ts
1032
+ await resolver.enforce('ticket.buy', {
1033
+ user: { age: 25, ticketsCount: 1 },
1034
+ env: { time: { hour: 18 } },
1035
+ });
1036
+
1037
+ ```
1038
+ Если разрешено — код продолжит выполнение.
1039
+ Если запрещено — будет выброшено исключение `AbilityError`.
1040
+
1041
+
1042
+
1043
+ **Проверка разрешений без исключений (resolve)**
1044
+
1045
+ `resolve` возвращает объект результата:
1046
+
1047
+ ```ts
1048
+ const result = await resolver.resolve('ticket.buy', {
1049
+ user: { age: 25, ticketsCount: 1 },
1050
+ env: { time: { hour: 18 } },
1051
+ });
1052
+
1053
+ if (result.isAllowed()) {
1054
+ console.log('Покупка разрешена');
1055
+ } else {
1056
+ console.log('Покупка запрещена');
1154
1057
  }
1058
+
1155
1059
  ```
1156
1060
 
1157
- ### Проектирование политик
1061
+ **Продавец может продавать только в рабочие часы***
1158
1062
 
1159
- - Используйте `deny` для запрещающих политик, `permit` для разрешающих
1160
- - Комбинируйте простые правила для сложной логики
1161
- - Давайте понятные имена правилам и политикам для упрощения отладки
1063
+ ```ts
1064
+ await resolver.enforce('ticket.sell', {
1065
+ user: { role: 'seller' },
1066
+ env: { time: { hour: 15 } },
1067
+ ticket: { status: 'available' },
1068
+ });
1162
1069
 
1163
- ## Отладка политик
1070
+ ```
1071
+
1072
+ **Подготовка данных для резолвера**
1073
+
1074
+ В примерах выше в резолвер передаются простые константные объекты:
1075
+
1076
+ ```ts
1077
+ resolver.enforce('ticket.buy', {
1078
+ user: { age: 25 },
1079
+ env: { time: { hour: 18 } },
1080
+ });
1081
+ ```
1082
+
1083
+ Это сделано для наглядности. В реальном приложении данные для резолвера должны формироваться динамически — из тех источников, которые доступны вашему серверу.
1084
+
1085
+ **Пользователь** (`user`) обычно берётся из:
1086
+
1087
+
1088
+ - JWT‑токена
1089
+ - сессии
1090
+ - базы данных
1091
+ - middleware авторизации
1092
+
1093
+ Пример:
1094
+
1095
+ ```ts
1096
+ const user = await db.users.findById(session.userId);
1097
+ ```
1098
+
1099
+ **Окружение (Environment)** (`env`)
1100
+
1101
+ Это любые внешние параметры, которые могут влиять на доступ:
1102
+
1103
+ - текущее время сервера
1104
+ - часовой пояс
1105
+ - IP‑адрес
1106
+ - заголовки запроса
1107
+ - конфигурация системы
1108
+
1109
+ Пример:
1110
+
1111
+ ```ts
1112
+ const env = {
1113
+ time: {
1114
+ hour: new Date().getHours(),
1115
+ },
1116
+ ip: req.ip,
1117
+ };
1118
+ ```
1164
1119
 
1165
- Используйте `resolveWithExplain()` для получения детальной информации о процессе проверки:
1120
+ **Ресурс** (например, `ticket`)
1121
+
1122
+ Если действие связано с конкретным объектом — его тоже нужно загрузить:
1166
1123
 
1167
1124
  ```ts
1168
- const explanations = resolver.resolveWithExplain('order.update', data);
1169
- explanations.forEach(exp => console.log(exp.toString()));
1170
- // ✓ policy «Запрет доступа для менеджеров» is match
1171
- // ✓ ruleSet «Менеджеры» is match
1172
- // ✓ rule «Отдел managers» is match
1173
- // ✗ rule «Роль manager» is mismatch
1174
- // ✓ ruleSet «Не администраторы» is match
1175
- // ✓ rule «Нет роли administrator» is match
1125
+ const ticket = await db.tickets.findById(req.params.ticketId);
1126
+ ```
1127
+
1128
+ **Контекст**
1129
+
1130
+ Контекст это объект, который вы передаёте в `resolve` или `enforce`.
1131
+ Он содержит **все данные**, которые могут понадобиться политикам:
1132
+
1133
+ - `user` — данные о текущем пользователе
1134
+ - `env` — данные окружения (время, IP, география, настройки системы)
1135
+ - `resource` или `ticket` — данные о сущности, над которой выполняется действие
1136
+ - любые другие объекты, которые вы используете в DSL
1137
+
1138
+ **Важно понимать:**
1139
+
1140
+ > Контекст формируется под конкретное действие и конкретные политики. Его не нужно хранить заранее — вы собираете его динамически перед вызовом резолвера.
1141
+
1142
+
1143
+ ## Производительность
1144
+
1145
+ В тестах задействовались политики на 10 условий, вложенные поля, environment.
1146
+
1147
+ **Tinybench** ([https://github.com/tinylibs/tinybench](https://github.com/tinylibs/tinybench))
1148
+
1149
+ | # | Task name | Latency avg (ns) | Latency med (ns) | Throughput avg (ops/s) | Throughput med (ops/s) | Samples |
1150
+ |---|-----------------------------------------|------------------------|------------------------|--------------------------|--------------------------|---------|
1151
+ | 0 | resolve() — no cache (heavy rules) | 646317 ± 0.32% | 632319 ± 8446.0 | 1555 ± 0.21% | 1581 ± 21 | 3095 |
1152
+ | 1 | resolve() — cold cache (heavy rules) | 636363 ± 0.38% | 623092 ± 7885.0 | 1581 ± 0.21% | 1605 ± 20 | 3143 |
1153
+ | 2 | resolve() — warm cache (heavy rules) | 631328 ± 0.26% | 621152 ± 6562.5 | 1590 ± 0.17% | 1610 ± 17 | 3168 |
1154
+
1155
+ ```
1156
+ Latency (ns)
1157
+ 650k | ███████████████████████████████████████ resolve() — no cache
1158
+ 640k | █████████████████████████████████████ resolve() — cold cache
1159
+ 630k | ████████████████████████████████████ resolve() — warm cache
1160
+ --------------------------------------------------------------
1161
+ no cache cold cache warm cache
1162
+ ```
1163
+
1164
+ ```
1165
+ Throughput (ops/s)
1166
+ 1600 | ███████████████████████████████████████ resolve() — warm cache
1167
+ 1590 | ██████████████████████████████████████ resolve() — cold cache
1168
+ 1580 | █████████████████████████████████████ resolve() — no cache
1169
+ --------------------------------------------------------------
1170
+ no cache cold cache warm cache
1171
+
1176
1172
  ```
1177
1173
 
1178
1174