@via-profit/ability 3.0.1 → 3.1.1

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
@@ -1,1181 +1,1147 @@
1
1
  # @via-profit/Ability
2
2
 
3
- > Набор сервисов, частично реализующих
4
- > принцип [Attribute Based Access Control](https://en.wikipedia.org/wiki/Attribute-based_access_control)
5
-
6
- Этот сервис позволяет создавать правила и политики, применять их к данным и проверять доступ на их основе.
7
-
8
- ## Содержание
9
-
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
- - [Проектирование политик](#проектирование-политик)
48
- - [Отладка политик](#отладка-политик)
49
- - [Лицензия](#лицензия)
3
+ > A set of services that partially implement the [Attribute Based Access Control](https://en.wikipedia.org/wiki/Attribute-based_access_control) principle.
4
+ > The package allows you to describe rules, combine them into groups, form policies, and apply them to data to determine permissions.
50
5
 
51
- ---
6
+ ## Language / Язык
52
7
 
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
- }
8
+ - [🇬🇧 English](/docs/en/README.md)
9
+ - [🇷🇺 Русский](/docs/ru/README.md)
10
+
11
+ ## Purpose
12
+
13
+ The package is intended as a **lightweight and extremely simple alternative** to heavy access control systems.
14
+ Without complex configurations, without dependencies just a minimal set of tools that allows you to describe rules and policies in a maximally simple DSL.
15
+
16
+ ## Table of Contents
17
+
18
+ - [Quick Start](#quick-start)
19
+ - [Fundamentals](#fundamentals)
20
+ - [DSL](#dsl)
21
+ - [Combining Policies](#combining-policies)
22
+ - [Policy Environment](#policy-environment)
23
+ - [TypeScript Type Generator](#typescript-type-generator)
24
+ - [Policy Debugging](#policy-debugging)
25
+ - [Troubleshooting](#troubleshooting)
26
+ - [Design Recommendations](#design-recommendations)
27
+ - [Examples](#examples)
28
+ - [Performance](#performance)
29
+ - [API Reference](./api.md)
30
+
31
+ ## Quick Start
32
+
33
+ Install the package, write DSL, call the parser, and run the resolver.
34
+
35
+ ### Installation
36
+
37
+ ```bash
38
+ npm install @via-profit/ability
39
+ ```
40
+
41
+ ```bash
42
+ yarn add @via-profit/ability
123
43
  ```
124
44
 
125
- Применение политики:
45
+ ```bash
46
+ pnpm add @via-profit/ability
47
+ ```
48
+
49
+ ### Example: Deny access to `passwordHash` for everyone except the owner
50
+
51
+ Suppose we have user data:
126
52
 
127
53
  ```ts
128
- const jsonConfig = { ... };
129
- AbilityPolicy.parse(jsonConfig).check({
130
- user: {
131
- department: 'managers',
132
- roles: ['manager', 'coach'],
133
- }
134
- });
54
+ const user = {
55
+ id: '1',
56
+ login: 'user-001',
57
+ passwordHash: '...',
58
+ };
135
59
  ```
136
60
 
137
- ---
61
+ We need to deny reading `passwordHash` to everyone except the user themselves.
62
+
63
+ #### DSL Policy
138
64
 
139
- ## Правила
65
+ In the policy language, this looks like:
140
66
 
141
- **Правила** выполняют условие проверки и возвращают результат. **Основная цель** - выполнить сравнение переданных
142
- значений субъекта и ресурса, а затем вернуть результат такого сравнения.
67
+ ```
68
+ deny permission.user.passwordHash if any:
69
+ viewer.id is not equals owner.id
70
+ ```
143
71
 
144
- ### Создание правила
72
+ **Explanation:**
145
73
 
146
- Создать правило можно двумя способами: создание через конструктор класса и парсинг JSON-конфига правила.
74
+ - `deny` policy effect (deny access)
75
+ - `permission.user.passwordHash` — permission key.
76
+ - `if any:` — start of the condition block
77
+ - `viewer.id is not equals owner.id` — rule: if the requester's ID is not equal to the owner's ID
147
78
 
148
- При создании необходимо указать следующие параметры:
79
+ If `viewer.id` is not equal to `owner.id`, the rule is satisfied and the policy returns `deny` — access denied. If the IDs match (i.e., the user requests their own data), the rule does not trigger, and access is allowed.
149
80
 
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` или значение, которое может быть строкой, числом, булеан значением или массивом строк, или чисел.
81
+ *Note: The permission key is formed according to the principle: `permission.` + your custom key in dot notation. For example, the key `foo.bar.baz` in DSL would be `permission.foo.bar.baz`.*
156
82
 
157
- _Создание правила через конструктор класса:_
83
+ #### Check in Code
158
84
 
159
85
  ```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
- });
86
+ import { AbilityDSLParser, AbilityResolver } from '@via-profit/ability';
169
87
 
170
- // сокращённая запись
171
- const rule2 = AbilityRule.equal(
172
- 'user.department', // subject
173
- 'managers' // resource
174
- );
88
+ const dsl = `
89
+ deny permission.user.passwordHash if any:
90
+ viewer.id is not equals owner.id
91
+ `;
92
+
93
+ const policies = new AbilityDSLParser(dsl).parse(); // obtain policies
94
+ const resolver = new AbilityResolver(policies); // create resolver
175
95
 
96
+ resolver.enforce('user.passwordHash', {
97
+ viewer: { id: '1' },
98
+ owner: { id: '2' },
99
+ }); // will throw an error — access denied
176
100
  ```
101
+ In `enforce`, the key is passed without the `permission.` prefix — it is automatically removed by the parser.
177
102
 
178
- _Создание правила через парсинг JSON-конфигурации:_
103
+ ## Fundamentals
179
104
 
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
- });
105
+ Let’s briefly list the key points you need to know before starting to use the package:
190
106
 
107
+ 1. The resolver (`AbilityResolver`) follows the **Default Deny** principle. This means that if no policy matches, the result is `deny` ([more details here](#troubleshooting)). To avoid unexpected `deny`, ensure there is at least one `permit` policy that can match. Only then add `deny` policies.
108
+ 2. Policies are applied sequentially. If multiple policies match, the result is determined by the last matching policy.
109
+ 3. Rules are executed sequentially.
110
+ 4. In a rule set (`RuleSet`) with the `all` comparison operator, further rule execution stops as soon as the first rule returns `mismatch`.
111
+ 5. Use [DSL](#dsl) to compose policies — it's simpler and more convenient.
112
+ 6. For storing policies on the server, use JSON. Policies can be exported to JSON and imported from JSON.
113
+ 7. Generally, rely on the principle: if permission is not explicitly granted → access is denied.
114
+ 8. Use the built-in cache only if your policies are incredibly complex and contain a large number of rules.
115
+
116
+ ---
117
+
118
+ ## DSL
119
+
120
+ > DSL - Domain-Specific Language
121
+
122
+ Ability DSL is a declarative language for describing access policies.
123
+ It allows you to define rules in a human-readable form using simple constructs: *policies*, *groups*, *rules*, and *annotations*.
124
+
125
+ ### Policy Structure
126
+
127
+ A policy consists of:
128
+
129
+ ```
130
+ <effect> <permission> if <all|any>:
131
+ <group>...
191
132
  ```
192
133
 
193
- ### Проверка правила
134
+ Where:
194
135
 
195
- Для проверки правила следует вызвать метод `check` класса `AbilityRule` передав объект проверяемого ресурса. Этот метод
196
- вернёт экземпляр класса
197
- `AbilityMatch`, при помощи методов которого можно определить имеется ли совпадение правила и переданных значений.
136
+ - **effect** `permit` or `deny`
137
+ - **permission** — a string of the form `permission.foo.bar`, where the `permission.` prefix is mandatory.
138
+ - **if all:** all groups must be true
139
+ - **if any:** — at least one group must be true
198
140
 
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
- });
141
+ A policy can contain one or more rule groups.
209
142
 
210
- const match = rule.check({
211
- user: {
212
- department: 'managers',
213
- },
214
- });
143
+ Example:
215
144
 
216
- const is = match.isEqual(AbilityMatch.match); // true
145
+ ```dsl
146
+ permit permission.order.update if any:
147
+ all of:
148
+ user.roles contains 'admin'
149
+ user.token is not null
217
150
 
151
+ any of:
152
+ user.roles contains 'developer'
153
+ user.login is equals 'dev'
218
154
  ```
219
155
 
220
- ## Получение пояснений (AbilityExplain)
156
+ > The `permission.` prefix is mandatory in DSL but is automatically removed by the parser. Internally, the permission is stored as `order.update`.
157
+
158
+ The example policy above says: permission `order.update` will be allowed if one of two conditions is met:
159
+ 1. `user.roles` contains 'admin' **and** `user.token` is not null
160
+ 2. `user.roles` contains 'developer' **or** `user.login` equals 'dev'
221
161
 
222
- Для отладки или аудита может быть полезно понять, *почему* было вынесено то или иное суждение о правах доступа. Метод `resolveWithExplain()` политики возвращает детальную информацию о проверке.
162
+ ### Permission Key
223
163
 
224
- ### Использование
164
+ Permission keys are written in dot notation but support the use of wildcard patterns with the `*` character. This allows grouping of keys and overriding policies with similar keys.
225
165
 
166
+ If multiple policies match a key, **all of them are executed**. The final result is determined by the **last matching policy**:
167
+
168
+ **Example of using wildcards**
169
+
170
+ | Policy (permission) | Key | Matches |
171
+ |---------------------|-----------------------|---------|
172
+ | `order.*` | `order.create` | yes |
173
+ | `order.*` | `order.update` | yes |
174
+ | `order.*` | `user.create` | no |
175
+ | `*.create` | `order.create` | yes |
176
+ | `*.create` | `user.create` | yes |
177
+ | `*.create` | `order.update` | no |
178
+ | `user.profile.*` | `user.profile.update` | yes |
179
+ | `user.profile.*` | `user.settings.update`| no |
180
+
181
+ **Example of a policy with wildcard**
226
182
  ```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
- };
183
+ import { AbilityDSLParser, AbilityResolver } from '@via-profit/ability';
271
184
 
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
- });
185
+ // DSL is not complete, shown for illustration only
186
+ const dsl = `
187
+ permit permission.order.*
188
+ deny permission.order.update
189
+ `;
285
190
 
286
- explain.forEach((e) => {
287
- console.debug(e.toString());
288
- });
191
+ const policies = new AbilityDSLParser(dsl).parse();
192
+ const resolver = new AbilityResolver(policies);
289
193
 
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
- };
194
+ await resolver.enforce('order.update', resource); // will throw AbilityError
195
+ ```
196
+
197
+ **Explanation**
198
+
199
+ In DSL, the order of policies matters:
200
+ the last matching policy wins.
201
+
202
+ Therefore:
203
+
204
+ 1. `permit` `permission.order.*` allows everything that starts with `order.`
205
+ 2. `deny` `permission.order.update` overrides this permission.
206
+
207
+ Execution result:
303
208
 
304
209
  ```
210
+ order.update → deny
211
+ order.create → permit
212
+ order.delete → permit
213
+ order.view → permit
214
+ ```
215
+
216
+ ### Comments
305
217
 
306
- Результат `console.debug`
218
+ Lines starting with the `#` symbol are considered comments and do not affect the evaluation of rules and policies.
219
+
220
+ ---
221
+
222
+ ### Annotations
223
+
224
+ Currently, only one annotation is supported: `name`, which will be used as the name for a policy, rule group, or rule.
225
+
226
+ Annotations are specified via comments:
307
227
 
308
228
  ```
309
- policy «Запрещает менять статус заявки...» is match
310
- ✓ ruleSet «Не администратор» is match
311
- ✓ rule «Нет роли администраторов» is match
312
- ruleSet «Проверка статуса...» is match
313
- ✓ rule «Текущий статус "не обработан"» is match
314
- rule «Будущий статус "завершен"» is match
229
+ # @name <name>
230
+ ```
231
+
232
+ Annotations apply to the **following entity**:
233
+
234
+ - policy
235
+ - group
236
+ - rule
237
+
238
+ Example:
239
+
240
+ ```dsl
241
+ # @name can order update
242
+ permit permission.order.update if any:
243
+ # @name authorized admin
244
+ all of:
245
+ # @name contains role admin
246
+ user.roles contains 'admin'
315
247
  ```
316
248
 
317
249
  ---
318
250
 
319
- ## Группы правил
251
+ ### Rule Groups
320
252
 
321
- **Группы правил** необходимы для объединения нескольких правил в группу. **Основная цель** - выполнить проверку каждого
322
- правила в группе и вернуть лишь один результат.
253
+ A group defines how the rules within it are combined:
323
254
 
324
- Создавая группу следует указывать метод сравнения (`compareMethod`), который необходим для вычисления значения всей
325
- группы при проверке правил.
255
+ ```
256
+ all of:
257
+ <rule>
258
+ <rule>
326
259
 
327
- При создании необходимо указать следующие параметры:
260
+ any of:
261
+ <rule>
262
+ <rule>
263
+ ```
328
264
 
329
- - **id** - `string` Уникальный идентификатор.
330
- - **name** - `string` Название группы.
331
- - **compareMethod** - `AbilityCompare` Способ сравнения правил в группе (`or` или `and`).
265
+ - `all of:` logical AND
266
+ - `any of:` logical OR
332
267
 
333
- _Влияние **compareMethod** на результат вычисления группы:_
268
+ `all of` means that the group is considered satisfied if all rules within the group match.
334
269
 
335
- - **`or`** - Результат всей группы примет значение `match`, если хотя бы одно из правил вернуло `match`.
336
- - **`and`** - Результат всей группы примет значение `match`, если все правила вернули `match`.
270
+ `any of` means that the group is considered satisfied if at least one rule within the group matches.
337
271
 
338
- ### Создание группы правил
272
+ Each group within a policy will be evaluated independently of other groups. The final result is determined by comparing the results of all groups in the policy.
339
273
 
340
- Создать группу правил можно двумя способами: создание через конструктор класса и парсинг JSON-конфига группы.
274
+ Groups can have annotations:
341
275
 
342
- _Создание группы через конструктор класса_:
276
+ ```dsl
277
+ # @name developer group
278
+ any of:
279
+ user.roles contains 'developer'
280
+ ```
343
281
 
344
- ```ts
345
- import { AbilityRuleSet, AbilityCompare } from '@via-profit/ability';
282
+ ---
346
283
 
347
- const ruleSet = new AbilityRuleSet({
348
- id: '<set-id>',
349
- name: 'Название группы',
350
- compareMethod: AbilityCompare.and,
351
- });
284
+ ### Rules
285
+
286
+ A rule is an atomic condition inside a policy. It defines under what data the policy is considered matched. Rules set the conditions that determine the effectiveness of a policy (`permit` or `deny`).
352
287
 
353
- // Добавление правил в группу
354
- ruleSet.addRules([
355
- new AbilityRule(...),
356
- new AbilityRule(...),
357
- ]);
288
+ A rule has the form:
289
+
290
+ ```
291
+ <subject> <operator> <value?> — the value is not required for some operators (e.g., `is null` does not require a value).
292
+ ```
358
293
 
294
+ #### Subject
359
295
 
360
- // Сокращённая запись
361
- const ruleSet2 = AbilityRuleSet.and([
362
- new AbilityRule(...),
363
- new AbilityRule(...),
364
- ]);
296
+ Identifier in dot notation:
365
297
 
298
+ ```
299
+ user.roles
300
+ env.time.hour
301
+ order.total
366
302
  ```
367
303
 
368
- _Создание группы через парсинг JSON-конфига группы_:
304
+ #### Operators
369
305
 
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
- });
306
+ *Synonyms are alternative forms of writing that are also supported by the parser.*
307
+
308
+ **Basic Comparison Operators**
309
+
310
+ | DSL Operator | Synonyms | Example | Description | Types |
311
+ |--------------|----------|---------|-------------|-------|
312
+ | **is equals** | `=`, `==`, `equals` | `age is equals 18` | Strict equality | number, string, boolean |
313
+ | **is not equals** | `!=`, `<>`, `not equals` | `role is not equals 'admin'` | Strict inequality | number, string, boolean |
314
+ | **greater than** | `>`, `gt` | `age greater than 18` | Greater than | number, date |
315
+ | **greater than or equal** | `>=`, `gte` | `age greater than or equal 18` | Greater than or equal | number, date |
316
+ | **less than** | `<`, `lt` | `age less than 18` | Less than | number, date |
317
+ | **less than or equal** | `<=`, `lte` | `age less than or equal 18` | Less than or equal | number, date |
318
+
319
+ **Null Operators**
320
+
321
+ | DSL Operator | Synonyms | Example | Description | Types |
322
+ |--------------|----------|---------|-------------|-------|
323
+ | **is null** | `== null`, `= null` | `middleName is null` | Value is absent | any |
324
+ | **is not null** | `!= null` | `middleName is not null` | Value is present | any |
325
+
326
+ **Operators for Lists (Arrays)**
327
+
328
+ | DSL Operator | Synonyms | Example | Description | Types |
329
+ |--------------|---------------------------|---------|-------------|-------|
330
+ | **in [...]** | - | `role in ['admin', 'manager']` | Value is in the list | number, string |
331
+ | **not in [...]** | - | `role not in ['banned']` | Value is not in the list | number, string |
332
+ | **contains** | `includes`, `has` | `tags contains 'vip'` | Array contains the element | array |
333
+ | **not contains** | `not includes`, `not has` | `tags not contains 'vip'` | Array does not contain the element | array |
387
334
 
335
+ **String Operators**
336
+
337
+ | DSL Operator | Synonyms | Example | Description | Types |
338
+ |--------------|----------|---------|-------------|-------|
339
+ | **starts with** | `begins with` | `email starts with 'admin@'` | String starts with | string |
340
+ | **not starts with** | — | `email not starts with 'test'` | String does not start with | string |
341
+ | **ends with** | — | `email ends with '.ru'` | String ends with | string |
342
+ | **not ends with** | — | `email not ends with '.com'` | String does not end with | string |
343
+ | **includes** | `contains substring` | `name includes 'lex'` | String contains substring | string |
344
+ | **not includes** | — | `name not includes 'test'` | String does not contain substring | string |
345
+
346
+ **Boolean Operators**
347
+
348
+ | DSL Operator | Synonyms | Example | Description | Types |
349
+ |--------------|----------|---------|-------------|-------|
350
+ | **is true** | `= true` | `isActive is true` | Value is true | boolean |
351
+ | **is false** | `= false` | `isActive is false` | Value is false | boolean |
352
+
353
+ **Length Operators**
354
+
355
+ | DSL Operator | Synonyms | Example | Description | Types |
356
+ |--------------|----------|---------|-------------|-------|
357
+ | **length equals** | `len =` | `tags length equals 3` | Length equals | array, string |
358
+ | **length greater than** | `len >` | `tags length greater than 2` | Length greater than | array, string |
359
+ | **length less than** | `len <` | `tags length less than 5` | Length less than | array, string |
360
+
361
+ #### Value
362
+
363
+ Supported values:
364
+
365
+ - strings `'text'`
366
+ - numbers `42`
367
+ - booleans `true` / `false`
368
+ - `null`
369
+ - arrays `[1, 2, 3]` / `['foo', false, null, 1, 2, '999']`
370
+
371
+ Examples:
372
+
373
+ ```dsl
374
+ # user age greater than 18
375
+ user.age greater than 18
376
+
377
+ # array of roles contains the role 'admin'
378
+ user.roles contains 'admin'
379
+
380
+ # order tag is either 'vip' or 'priority'
381
+ order.tag in ['vip', 'priority']
382
+
383
+ # user token is not null
384
+ user.token is not null
385
+
386
+ # user login is longer than 12 characters
387
+ user.login length greater than 12
388
388
  ```
389
389
 
390
- ### Проверка группы правил
390
+ ---
391
391
 
392
- Для проверки группы правил следует вызвать метод `check` класса `AbilityRuleSet` передав объект проверяемого ресурса.
393
- Этот метод вернёт экземпляр класса `AbilityMatch`, при помощи методов которого можно определить имеется ли совпадение
394
- для группы и переданных значений.
392
+ ### Implicit Group
395
393
 
396
- ```ts
397
- import { AbilityRuleSet, AbilityCompare } from '@via-profit/ability';
394
+ If rules are written without `all of:` or `any of:`, they are combined using the policy operator:
398
395
 
399
- const ruleSet = new AbilityRuleSet({
400
- id: '<set-id>',
401
- name: 'Название группы',
402
- compareMethod: AbilityCompare.and,
403
- }).addRules([
404
- new AbilityRule(...),
405
- new AbilityRule(...),
406
- ]);
396
+ ```dsl
397
+ permit permission.order.update if all:
398
+ user.roles contains 'admin'
399
+ user.token is not null
400
+ ```
407
401
 
408
- const match = rule.check({ ... });
402
+ Equivalent to:
409
403
 
410
- const is = match.isEqual(AbilityMatch.match);
404
+ ```dsl
405
+ permit permission.order.update if all:
406
+ all of:
407
+ user.roles contains 'admin'
408
+ user.token is not null
411
409
  ```
412
410
 
413
- ___
411
+ The implicit group always matches the policy operator (`if all` or `if any`).
412
+
413
+ ---
414
+
415
+ ### Complete Example
414
416
 
415
- ## Политики
417
+ ```dsl
418
+ # @name order update allowed
419
+ permit permission.order.update if any:
416
420
 
417
- **Политики** включают в себя группы правил. Основная цель - выполнить проверку всех вложенных групп, сравнить результат
418
- выполнения групп и вернуть один единственный результат.
421
+ # @name if admin
422
+ all of:
423
+ user.roles contains 'admin'
424
+ user.token is not null
419
425
 
420
- ### Создание политики
426
+ # @name if developer
427
+ any of:
428
+ user.roles contains 'developer'
429
+ user.login is equals 'dev'
430
+ ```
421
431
 
422
- Создать политику можно двумя способами: создание через конструктор класса и парсинг JSON-конфига политики.
432
+ ## Combining Policies
423
433
 
424
- При создании политики необходимо указать следующие параметры:
434
+ In a real project, you should use multiple policies at once.
425
435
 
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[]` Массив групп (см. [Группы правил](#группы-правил))
436
+ TODO: using multiple policies
438
437
 
439
- **Замечание** - Политика может быть запрещающей (`effect` = `deny`) и разрешающей (`effect` = `permit`). Если вам
440
- необходимо ограничить какой-либо доступ, например, пользователю с недостаточными правами, то следует создавать политику
441
- с эффектом `deny`.
438
+ ## Policy Environment
442
439
 
443
- _Создание политики через конструктор класса_:
440
+ **Environment** is an object containing context data that does not belong to either the user or the resource.
441
+ The content of the object is defined by the developer and can be any object consisting of primitives.
444
442
 
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
- });
443
+ - request time,
444
+ - IP address,
445
+ - device parameters,
446
+ - request headers,
447
+ - session context,
448
+ - any other external conditions.
449
+
450
+ **Examples:**
464
451
 
452
+ ```ts
453
+ type Environment = {
454
+ time: {
455
+ hour: number;
456
+ };
457
+ ip: string;
458
+ geo: {
459
+ country: string;
460
+ };
461
+ };
465
462
  ```
466
463
 
467
- _Создание политики через парсинг JSON-конфига_:
464
+ Environment is passed to `resolve()` and `enforce()` as the third argument:
468
465
 
469
466
  ```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
- });
467
+ await resolver.resolve('order.update', resource, environment);
468
+ await resolver.enforce('order.update', resource, environment);
469
+ ```
470
+
471
+ ### Using environment in rules
472
+
473
+ In a policy, you can refer to environment via the `env.*` path.
495
474
 
475
+ Example policy that denies order updates at night (10 PM – 6 AM):
476
+
477
+ ```dsl
478
+ # @name Deny updates at night
479
+ deny permission.order.update if all:
480
+ env.time.hour less than 6
481
+ env.time.hour greater or equal than 22
496
482
  ```
497
483
 
498
- ### Проверка политики
484
+ **Retrieving values from environment**
485
+
486
+ If a path is specified in a rule:
487
+
488
+ - `env.*` → value is taken from environment
489
+ - `user.*`, `order.*`, `profile.*` → from resource
490
+ - literal (`18`, `"admin"`, `true`) → used as is
499
491
 
500
- Для проверки политики правил следует вызвать метод `check` класса `AbilityPolicy` передав объект проверяемого ресурса.
501
- Этот метод вернёт экземпляр класса `AbilityMatch`, при помощи методов которого можно определить имеется ли совпадение
502
- для группы и переданных значений.
492
+ Example:
503
493
 
504
494
  ```ts
505
- import { AbilityPolicy } from '@via-profit/ability';
495
+ subject: "env.geo.country"
496
+ resource: "user.country"
497
+ condition: "equal"
498
+ ```
506
499
 
507
- const policy = AbilityPolicy.parse({ ... });
500
+ ### Environment in TypeScript
508
501
 
509
- const match = policy.check({ ... });
502
+ The Environment type is set at the `AbilityResolver` level:
510
503
 
511
- const is = match.isEqual(AbilityMatch.match);
504
+ ```ts
505
+ const resolver = new AbilityResolver<Resources, Environment>(policies);
512
506
  ```
513
507
 
514
- ___
508
+ This allows:
515
509
 
516
- ## Управление политиками
510
+ - getting autocompletion in IDE,
511
+ - checking the correctness of `env.*` paths,
512
+ - avoiding errors when passing environment.
517
513
 
518
- Класс `AbilityResolver` это основной инструмент для применения политик в рантайме. Он решает две ключевые задачи:
514
+ > If a rule uses `env.*` but environment is not passed, then the value of `env.*` will be `undefined`, and the comparison will be performed as if the environment were absent.
519
515
 
520
- 1. **Фильтрация политик по действию** — выбирает только те политики, которые применимы к выполняемой операции
521
- 2. **Оценка разрешений** — последовательно проверяет отобранные политики и возвращает итоговый результат (разрешено/запрещено)
516
+ ## TypeScript Type Generator
522
517
 
523
- ### Зачем нужен AbilityResolver?
518
+ `AbilityParser.generateTypeDefs()` generates TypeScript types based on policies, allowing you to avoid discrepancies between types and data in policies.
524
519
 
525
- Представим, что в системе есть десятки политик, каждая на своё действие:
526
- - `order.create` — правила создания заказа
527
- - `order.update` — правила обновления заказа
528
- - `order.delete` — правила удаления заказа
529
- - `user.profile.update` — правила обновления профиля
530
- - и так далее...
520
+ **Usage Example**
531
521
 
532
- Когда пользователь пытается создать заказ, нам нужно проверить только политики, связанные с действием `order.create`, игнорируя все остальные. Именно это и делает `AbilityResolver`.
522
+ First, you need to prepare an array of policies. Policies can be stored in DSL or JSON and parsed into an array of ready-made policies. In this example, for clarity, policies are stored in DSL.
533
523
 
534
- ### Использование wildcard (*) в действиях
524
+ ```ts
525
+ // scripts/policies.ts
526
+
527
+ import { AbilityDSLParser } from './AbilityDSLParser';
535
528
 
536
- `AbilityResolver` поддерживает использование символа звездочки (`*`) в названиях действий. Это позволяет создавать политики, которые применяются к целым группам операций.
529
+ const dsl = `
530
+ # @name Update order
531
+ permit permission.order.update if all:
537
532
 
538
- #### Правила сопоставления с wildcard:
533
+ # @name Owner check
534
+ all of:
535
+ # @name User is owner
536
+ user.id = order.ownerId
537
+ `;
539
538
 
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` | ❌ Не совпадает |
539
+ const policies = new AbilityDSLParser(dsl).parse();
552
540
 
553
- #### Примеры использования wildcard:
541
+ export default policies;
542
+ ```
554
543
 
555
544
  ```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
- }
545
+ // scripts/generate-types.ts
546
+ import { writeFileSync } from 'node:fs';
547
+ import { AbilityParser } from '@via-profit/ability';
548
+ import policies from './policies.json';
564
549
 
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
- }
550
+ const typedefs = AbilityParser.generateTypeDefs(policies);
573
551
 
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
- }
552
+ writeFileSync('./src/ability/types.generated.ts', typedefs, 'utf8');
582
553
  ```
583
554
 
584
- #### Приоритет и множественное совпадение
555
+ **Generated File (example)**
556
+
557
+ ```ts
558
+ // src/ability/types.generated.ts
559
+
560
+ // Automatically generated by via-profit/ability
561
+ // Do not edit manually
562
+ export type Resources = {
563
+ 'order.update': {
564
+ readonly user: {
565
+ readonly id: string;
566
+ };
567
+ readonly order: {
568
+ readonly ownerId: string;
569
+ };
570
+ };
571
+ };
572
+ ```
585
573
 
586
- Если несколько политик подходят под проверяемое действие, будут применены **все** подходящие политики. Результат определяется последней сработавшей политикой:
574
+ **Usage in code**
587
575
 
588
576
  ```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
- ];
577
+ import { AbilityResolver, AbilityPolicy } from '@via-profit/ability';
578
+ import type { Resources } from './ability/types.generated';
601
579
 
602
- const resolver = new AbilityResolver(policies);
580
+ const resolver = new AbilityResolver<Resources>(
581
+ AbilityPolicy.parseAll(policies),
582
+ );
603
583
 
604
- // При проверке order.update сработают ОБЕ политики
605
- // Результат будет deny, так как это эффект последней сработавшей политики,
606
- // таким образом, каждая последующая политика считается важнее предыдущей.
607
- // Это применительно для ситуаций, когда необходимо, что называется, наложить вето
608
- // на принятые ранее решения вышестоящих политик
609
- resolver.enforce('order.update', data);
584
+ await resolver.enforce('order.update', {
585
+ user: { id: 'u1' },
586
+ order: { ownerId: 'u1' },
587
+ });
610
588
  ```
611
589
 
612
- **Каждая последующая политика считается важнее предыдущей.
613
- Это применительно для ситуаций, когда необходимо, что называется, наложить вето
614
- на принятые ранее решения вышестоящих политик**
590
+ ## Policy Debugging
591
+
592
+ ### Explanations
593
+
594
+ To simplify policy debugging, a special `AbilityResult` class is used, which is already included in the final evaluation result. `AbilityResult` encapsulates the outcome of applying all matching policies to a permission key and resource.
615
595
 
616
- #### Комбинирование точных действий и wildcard
596
+ `AbilityResult` contains:
617
597
 
618
- Вы можете комбинировать точные действия и wildcard для создания гибкой системы прав:
598
+ - a list of evaluated policies,
599
+ - methods to determine the final effect,
600
+ - methods to get explanations in textual representation.
601
+
602
+ Example:
619
603
 
620
604
  ```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
- ];
605
+ const result = await resolver.resolve('order.update', resource);
606
+
607
+ if (result.isDenied()) {
608
+ console.log('Access denied');
609
+ }
610
+
611
+ const explanations = result.explain(); // AbilityExplain
612
+
613
+ // console.log(explanations.toString());
643
614
  ```
644
615
 
645
- ### Как это работает
616
+ ### AbilityExplain
617
+
618
+ `AbilityExplain` and related classes (`AbilityExplainPolicy`, `AbilityExplainRuleSet`, `AbilityExplainRule`) allow you to get a human-readable explanation:
619
+
620
+ - which policy matched,
621
+ - which rule groups matched,
622
+ - which rules did not pass,
623
+ - which effect was applied.
624
+
625
+ Usage example:
646
626
 
647
627
  ```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
- ];
628
+ const result = await resolver.resolve('order.update', resource);
629
+ const explanations = result.explain();
684
630
 
685
- // Создаем экземпляры политик
686
- const policies = AbilityPolicy.parseAll(configs);
631
+ console.log(explanations.toString());
632
+ ```
687
633
 
688
- // Создаем резолвер со всеми политиками
634
+ Example output:
635
+
636
+ ```
637
+ ✓ policy «Deny order update for managers» is match
638
+ ✓ ruleSet «Managers» is match
639
+ ✓ rule «Department managers» is match
640
+ ✗ rule «Role manager» is mismatch
641
+ ✓ ruleSet «Not administrators» is match
642
+ ✓ rule «No role administrator» is match
643
+ ```
644
+
645
+ ### Output Format
646
+
647
+ Currently, only one output format is supported — textual.
648
+
649
+ The output follows the principle: `<policy | ruleSet | rule> <name> <is match | is mismatch>`
650
+
651
+ ## Troubleshooting
652
+
653
+ ### Decision‑Making Model (Default Deny)
654
+
655
+ > Why does a `deny` policy not turn into `permit` if its conditions are not met?
656
+
657
+ Consider a policy that **denies** access to a user aged 16:
658
+
659
+ ```ts
660
+ const dsl = `
661
+ deny permission.test if all:
662
+ user.age is equals 16
663
+ `;
664
+
665
+ const policies = new AbilityDSLParser(dsl).parse();
689
666
  const resolver = new AbilityResolver(policies);
690
667
 
691
- // При выполнении действия указываем только нужный action
692
- // AbilityResolver автоматически отфильтрует политики и проверит их
693
- resolver.enforce('order.create', {
694
- user: {
695
- department: 'managers',
696
- roles: ['manager']
697
- },
698
- order: {
699
- amount: 5000
700
- }
668
+ const result = await resolver.resolve('test', {
669
+ user: { age: 16 },
701
670
  });
671
+
672
+ console.log(result.isDenied()); // true ✔
673
+ console.log(result.isAllowed()); // false ✔
702
674
  ```
703
675
 
704
- ### Проверка соответствия действия
676
+ In this case, everything is obvious:
677
+ the condition is met → the policy matches → effect `deny` → access denied.
705
678
 
706
- Метод `isInActionContain` позволяет проверить, соответствует ли действие шаблону с wildcard:
679
+ **What happens if the conditions are *not met*?**
707
680
 
708
681
  ```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
682
+ const result = await resolver.resolve('test', {
683
+ user: { age: 12 },
684
+ });
685
+
686
+ console.log(result.isDenied()); // true ✔
687
+ console.log(result.isAllowed()); // false ✔
714
688
  ```
715
689
 
716
- Этот метод используется внутри `AbilityResolver` для фильтрации политик, но может быть полезен и в пользовательском коде.
690
+ At first glance, it might seem that if the condition is not met, the policy should “allow” access.
691
+ But that is **not the case**.
717
692
 
718
- ### Методы AbilityResolver
693
+ **Decision‑Making Model: `Default Deny`**
719
694
 
720
- #### `enforce()` строгая проверка
695
+ `AbilityResolver` uses the classic security model:
721
696
 
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
- ```
697
+ > **If there is no matching permit‑policy → access is denied.**
736
698
 
737
- **Важно**: `enforce()` выбрасывает исключение, если хотя бы одна подходящая политика вернула `deny`. Если ни одна политика не сработала или все вернули `permit` — исключения не будет.
699
+ **What happens in this example:**
738
700
 
739
- #### `resolve()` мягкая проверка
701
+ 1. The `deny` policy exists, but its condition is **not met**
702
+ → the policy gets status `mismatch`.
740
703
 
741
- ```typescript
742
- const result = resolver
743
- .resolve('order.update', data)
744
- .isDeny(); // true если доступ запрещен
704
+ 2. The `deny` policy **is not applied** because the conditions did not match.
745
705
 
746
- if (result) {
747
- // Самостоятельно обрабатываем запрет
748
- return { error: 'Access denied' };
749
- }
706
+ 3. There is no `permit` policy.
750
707
 
751
- // Продолжаем выполнение
752
- await updateOrder(data);
753
- ```
708
+ 4. Since there is no permit policy → the final decision:
709
+ **deny (by default)**.
754
710
 
755
- #### `resolveWithExplain()` — проверка с объяснением
711
+ **Summary**
756
712
 
757
- ```typescript
758
- const explanations = resolver.resolveWithExplain('order.update', data);
713
+ - `deny` with matching conditions → **deny**
714
+ - `deny` with non‑matching conditions → **deny (default deny)**
715
+ - `permit` with matching conditions → **allow**
716
+ - `permit` with non‑matching conditions → **deny (default deny)**
759
717
 
760
- explanations.forEach(explain => {
761
- console.log(explain.toString());
762
- // ✓ policy «Политика обновления заказа» is match
763
- // ✗ ruleSet «Проверка владельца» is mismatch
764
- // ✓ ruleSet «Проверка статуса» is match
765
- });
718
+ **Conclusion**
766
719
 
767
- if (resolver.isDeny()) {
768
- console.log('Доступ запрещен по причине:');
769
- explanations.forEach(e => console.log(e.toString()));
770
- }
771
- ```
720
+ **Access is allowed only if there is an explicit permit.**
772
721
 
773
- ### Интеграция с TypeScript
722
+ ## Design Recommendations
774
723
 
775
- Для полной типобезопасности определите интерфейс `Resources`, где ключи — это возможные действия, а значения — структура данных, требуемая для проверки:
724
+ ### Naming Access Keys
776
725
 
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
-
790
- 'order.update': {
791
- readonly user: {
792
- readonly id: string;
793
- };
794
- readonly order: {
795
- readonly id: string;
796
- readonly status: string;
797
- readonly ownerId: string;
798
- };
799
- };
800
-
801
- 'user.profile.update': {
802
- readonly user: {
803
- readonly id: string;
804
- };
805
- readonly profile: {
806
- readonly userId: string;
726
+ - Use hierarchical keys: `permission.order.create`, `permission.order.update.status`, `permission.user.profile.update`.
727
+ - Group by domains: `permission.user.*`, `permission.order.*`, `permission.product.*`.
728
+ - Do not mix different domains in one key.
729
+
730
+ ### Data Structure
731
+
732
+ - Explicitly describe `Resources` in TypeScript.
733
+ - Do not pass “extra” fields — this complicates understanding.
734
+ - Strive to keep the data structure for a given `permission` stable.
735
+
736
+ ### Policy Design
737
+
738
+ - General rules — via wildcard (`permission.order.*`).
739
+ - Specific restrictions — via exact actions (`permission.order.update`).
740
+ - Use `effect: deny` for prohibitions.
741
+ - Use `effect: permit` for permissions.
742
+
743
+ ### Common Mistakes
744
+
745
+ - Expecting that absence of matching policies means allow.
746
+ - Mixing business logic and access policies.
747
+ - Too large policies with dozens of rules — better to break them down.
748
+
749
+ ### Example of Use on the Frontend (React)
750
+
751
+ **Hook for checking policies**
752
+
753
+ ```tsx
754
+ // hooks/use-ability.ts
755
+ import { useEffect, useState } from 'react';
756
+ import { AbilityResolver } from '@via-profit/ability';
757
+ import { Resources } from './generated-types';
758
+
759
+ export function useAbility<Permission extends keyof Resources>(
760
+ resolver: AbilityResolver<Resources>,
761
+ permission: Permission,
762
+ resource: Resources[Permission],
763
+ ) {
764
+ const [allowed, setAllowed] = useState<boolean | null>(null);
765
+
766
+ useEffect(() => {
767
+ let cancelled = false;
768
+
769
+ async function check() {
770
+ try {
771
+ const result = await resolver.resolve(permission, resource);
772
+ if (!cancelled) {
773
+ setAllowed(result.isAllowed());
774
+ }
775
+ } catch {
776
+ if (!cancelled) {
777
+ setAllowed(false);
778
+ }
779
+ }
780
+ }
781
+
782
+ check();
783
+
784
+ return () => {
785
+ cancelled = true;
807
786
  };
808
- };
809
- };
787
+ }, [resolver, permission, resource]);
810
788
 
811
- // Теперь TypeScript будет проверять корректность передаваемых данных
812
- resolver.enforce('order.create', {
813
- user: {
814
- department: 'managers',
815
- roles: ['manager']
816
- },
817
- order: {
818
- amount: 5000 // все поля на месте
789
+ return allowed;
790
+ }
791
+ ```
792
+
793
+ **Usage in a component**
794
+
795
+ ```tsx
796
+ function OrderUpdateButton({ order, user }) {
797
+ const allowed = useAbility(resolver, 'order.update', {
798
+ user,
799
+ order,
800
+ });
801
+
802
+ if (allowed === null) {
803
+ return null; // or loading spinner
819
804
  }
820
- });
821
805
 
822
- // Ошибка компиляции - не хватает required полей
823
- resolver.enforce('order.create', {
824
- user: {
825
- department: 'managers'
826
- // missing roles
806
+ if (!allowed) {
807
+ return null;
827
808
  }
828
- });
809
+
810
+ return <button>Update order</button>;
811
+ }
829
812
  ```
830
813
 
831
- ### Как формируется итоговое решение?
814
+ ## Examples
832
815
 
833
- 1. Resolver собирает все политики, у которых `action` соответствует запрошенному (поддерживается wildcard `*`)
834
- 2. Последовательно проверяет каждую политику методом `check()`
835
- 3. Если политика вернула `match`, запоминает её `effect` (permit/deny)
836
- 4. Возвращается `effect` **последней сработавшей политики**
816
+ ### Example of a Complex Multi‑Level Policy
837
817
 
838
- ```typescript
839
- // Пример с несколькими политиками на одно действие
840
- const policies = [
841
- permitPolicy, // permit, но не match
842
- denyPolicy, // deny и match
843
- permitPolicy2 // permit, но не match
844
- ];
818
+ Below is a multi‑level set of policies, using a cinema example (fictional).
845
819
 
846
- resolver.enforce('some.action', data);
847
- // Результат: deny (от последней сработавшей политики)
848
- ```
820
+ **The example demonstrates:**
821
+ - working with roles (admin, seller, manager, VIP, banned),
822
+ - time constraints (`env.time.hour`),
823
+ - wildcard permissions (`permission.*`),
824
+ - ticket quantity limits,
825
+ - prohibition on selling already sold tickets,
826
+ - combination of `permit`/`deny` policies,
827
+ - policy priority and Default Deny model.
849
828
 
850
- ### Когда использовать Resolver?
829
+ **Brief description of rules**
830
+ - **Administrator**
831
+ Has wildcard permissions (`permission.*`) and can perform any action.
832
+ Can edit ticket prices.
851
833
 
852
- - **В API endpoints** — проверка прав перед выполнением операции
853
- - **В middleware** централизованная проверка доступа
854
- - **В сервисах** — защита бизнес-логики
855
- - **В клиентском коде** — условный рендеринг UI на основе прав
834
+ - **Seller**
835
+ Can sell tickets only during working hours (09:00–23:00).
836
+ Cannot sell tickets if:
837
+ - the cinema is closed,
838
+ - the ticket is already sold.
856
839
 
857
- Resolver делает систему прав предсказуемой, типобезопасной и легко расширяемой.
840
+ - **Manager**
841
+ Has the same rights as a seller.
858
842
 
859
- ## API Reference
843
+ - **Buyers**
844
+ - A user older than 21 can buy tickets.
845
+ - A VIP user can buy tickets at any time.
846
+ - A banned user (`status = banned`) cannot buy tickets.
847
+ - Any user cannot buy more than 6 tickets.
860
848
 
861
- ### Класс AbilityCode
849
+ **Policy Diagram**
862
850
 
863
- Базовый абстрактный класс для всех кодовых значений в системе.
851
+ ```mermaid
852
+ flowchart LR
864
853
 
865
- | Метод | Аргументы | Возвращаемое значение | Описание |
866
- |-------|-----------|----------------------|----------|
867
- | `isEqual()` | `compareWith: AbilityCode<T> \| null` | `boolean` | Сравнивает текущий код с другим экземпляром |
868
- | `isNotEqual()` | `compareWith: AbilityCode<T> \| null` | `boolean` | Проверяет неравенство с другим экземпляром |
854
+ %% ==== ROLES ====
869
855
 
870
- **Геттеры:**
871
- - `code: T` - возвращает сырое значение кода (строку или число)
856
+ subgraph Roles[Roles]
857
+ A[Administrator]
858
+ B[Seller]
859
+ C[Manager]
860
+ end
872
861
 
873
- ---
862
+ subgraph Buyers[Buyers]
863
+ U1[User > 21]
864
+ U2[VIP user]
865
+ U3[Banned user]
866
+ end
874
867
 
875
- ### Класс AbilityMatch
868
+ %% ==== ADMIN ====
876
869
 
877
- Представляет возможные состояния результата проверки правил.
870
+ A --> A1[Wildcard: permission.*]
871
+ A --> A2[Edit ticket price]
878
872
 
879
- **Статические свойства:**
880
- - `AbilityMatch.pending` - ожидание проверки
881
- - `AbilityMatch.match` - совпадение найдено
882
- - `AbilityMatch.mismatch` - совпадение не найдено
873
+ A1 --> FINAL[Final decision]
874
+ A2 --> FINAL
883
875
 
884
- Каждое свойство является экземпляром класса `AbilityMatch` и наследует все его методы.
876
+ %% ==== SELLER ====
885
877
 
886
- ---
878
+ B --> B1[Sell tickets]
887
879
 
888
- ### Класс AbilityCondition
880
+ B1 -->|09:00–23:00| B2[Allowed]
881
+ B1 -->|Outside hours| D2[Denied]
882
+ B1 -->|ticket.status = sold| D3[Denied]
889
883
 
890
- Определяет операторы сравнения для правил доступа.
884
+ B2 --> FINAL
885
+ D2 --> FINAL
886
+ D3 --> FINAL
891
887
 
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`)
888
+ %% ==== MANAGER ====
901
889
 
902
- | Метод | Аргументы | Возвращаемое значение | Описание |
903
- |-------|-----------|----------------------|----------|
904
- | `fromLiteral()` | `literal: AbilityConditionLiteralType` | `AbilityCondition` | Создает экземпляр условия из литерального имени (например, 'equal' → '=') |
890
+ C --> C1[Sell tickets as seller]
891
+ C1 --> FINAL
905
892
 
906
- **Геттеры:**
907
- - `literal: AbilityConditionLiteralType` - возвращает литеральное имя оператора ('equal', 'not_equal' и т.д.)
893
+ %% ==== BUYERS ====
908
894
 
909
- ---
895
+ U1 --> U1A[Buy tickets]
896
+ U1A -->|ticketsCount < 6| U1OK[Allowed]
897
+ U1A -->|ticketsCount ≥ 6| U1DENY[Denied]
910
898
 
911
- ### Класс AbilityCompare
899
+ U2 --> U2A[Buy tickets anytime]
900
+ U2A -->|ticketsCount < 6| U2OK[Allowed]
901
+ U2A -->|ticketsCount ≥ 6| U2DENY[Denied]
912
902
 
913
- Определяет методы логического сравнения для групп правил.
903
+ U3 --> U3A[Denied to buy tickets]
914
904
 
915
- **Статические свойства:**
916
- - `AbilityCompare.and` - логическое И (все правила должны совпасть)
917
- - `AbilityCompare.or` - логическое ИЛИ (достаточно одного совпадения)
905
+ U1OK --> FINAL
906
+ U1DENY --> FINAL
907
+ U2OK --> FINAL
908
+ U2DENY --> FINAL
909
+ U3A --> FINAL
918
910
 
919
- ---
911
+ %% ==== DENY RULES ====
920
912
 
921
- ### Класс AbilityPolicyEffect
913
+ D1[Denied to buy tickets if user.status = banned] --> FINAL
914
+ ```
922
915
 
923
- Определяет эффект применения политики.
916
+ **DSL Policies**
924
917
 
925
- **Статические свойства:**
926
- - `AbilityPolicyEffect.deny` - запрет доступа
927
- - `AbilityPolicyEffect.permit` - разрешение доступа
918
+ ```dsl
919
+ ############################################################
920
+ # @name Admin can edit ticket price
921
+ permit permission.ticket.price.edit if all:
922
+ user.role is equals 'admin'
928
923
 
929
- ---
930
924
 
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` | Создает правило с условием "больше или равно" |
925
+ ############################################################
926
+ # @name Seller can sell tickets during working hours
927
+ permit permission.ticket.sell if all:
928
+ user.role is equals 'seller'
929
+ all of:
930
+ env.time.hour greater than or equal 9
931
+ env.time.hour less than or equal 23
963
932
 
964
- ---
965
933
 
966
- ### Класс AbilityRuleSet
934
+ ############################################################
935
+ # @name Users older than 21 can buy tickets
936
+ permit permission.ticket.buy if all:
937
+ user.age greater than 21
967
938
 
968
- Группирует несколько правил для совместной проверки.
969
939
 
970
- **Свойства:**
971
- - `state: AbilityMatch` - текущее состояние группы после вызова `check()`
972
- - `rules: AbilityRule[]` - массив правил в группе
973
- - `compareMethod: AbilityCompare` - метод сравнения (AND/OR)
974
- - `name: string` - название группы
975
- - `id: string` - уникальный идентификатор
940
+ ############################################################
941
+ # @name VIP users can buy tickets anytime
942
+ permit permission.ticket.buy if all:
943
+ user.isVIP is true
976
944
 
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` | Статический метод. Создает группу с логическим ИЛИ |
986
945
 
987
- ---
946
+ ############################################################
947
+ # @name Deny buying tickets if user is banned
948
+ deny permission.ticket.buy if all:
949
+ user.status is equals 'banned'
988
950
 
989
- ### Класс AbilityPolicy
990
951
 
991
- Объединяет группы правил в полноценную политику доступа.
952
+ ############################################################
953
+ # @name Deny selling tickets if cinema is closed
954
+ deny permission.ticket.sell if all:
955
+ any of:
956
+ env.time.hour less than 9
957
+ env.time.hour greater than 23
992
958
 
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
959
 
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. Удобен для загрузки нескольких политик одновременно. |
960
+ ############################################################
961
+ # @name Manager can do everything seller can
962
+ permit permission.ticket.sell if all:
963
+ user.role is equals 'manager'
1010
964
 
1011
- ---
1012
965
 
1013
- ### Класс AbilityResolver
966
+ ############################################################
967
+ # @name Admin wildcard permissions
968
+ permit permission.* if all:
969
+ user.role is equals 'admin'
1014
970
 
1015
- Управляет множеством политик и их выполнением.
1016
971
 
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` | Статический метод. Проверяет, соответствует ли действие шаблону (поддерживает `*`) |
972
+ ############################################################
973
+ # @name Limit tickets per user (max 6)
974
+ deny permission.ticket.buy if all:
975
+ user.ticketsCount greater than or equal 6
1028
976
 
1029
- ---
1030
977
 
1031
- ### Класс AbilityExplain
978
+ ############################################################
979
+ # @name Cannot sell already sold tickets
980
+ deny permission.ticket.sell if all:
981
+ ticket.status is equals 'sold'
982
+ ```
1032
983
 
1033
- Представляет человекочитаемое объяснение результата проверки.
984
+ Below is how to use the policies above in Node.js + TypeScript.
1034
985
 
1035
- **Свойства:**
1036
- - `type: AbilityExplainType` - тип элемента ('policy' \| 'rule' \| 'ruleSet')
1037
- - `children: AbilityExplain[]` - дочерние элементы объяснения
1038
- - `name: string` - название элемента
1039
- - `match: AbilityMatch` - результат проверки
986
+ **Preparing Policies**
1040
987
 
1041
- | Метод | Аргументы | Возвращаемое значение | Описание |
1042
- |-------|-----------|----------------------|----------|
1043
- | `toString()` | `indent: number = 0` | `string` | Форматирует объяснение в читаемый текст с отступами |
988
+ ```ts
989
+ import { AbilityDSLParser } from '@via-profit/ability';
990
+ import cinemaDSL from './policies/cinema.dsl';
1044
991
 
1045
- **Наследники:**
1046
- - `AbilityExplainRule` - объяснение для правила
1047
- - `AbilityExplainRuleSet` - объяснение для группы правил
1048
- - `AbilityExplainPolicy` - объяснение для политики
992
+ export const policies = new AbilityDSLParser(cinemaDSL).parse();
993
+ ```
1049
994
 
1050
- ---
995
+ **Creating the Resolver**
1051
996
 
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
- // }
997
+ ```ts
998
+ import { AbilityResolver } from '@via-profit/ability';
999
+ import { policies } from './policies';
1000
+
1001
+ const resolver = new AbilityResolver(policies);
1116
1002
  ```
1117
1003
 
1118
- ---
1004
+ **Checking Permissions (enforce)**
1119
1005
 
1120
- ### Классы ошибок
1006
+ Example: buying a ticket.
1121
1007
 
1122
- | Класс | Назначение |
1123
- |-------|------------|
1124
- | `AbilityError` | Общая ошибка доступа, выбрасывается при запрете в `enforce()` |
1125
- | `AbilityParserError` | Ошибка парсинга конфигурации или генерации типов |
1008
+ The `enforce` method throws an `AbilityError` if access is denied.
1126
1009
 
1127
- ## Рекомендации по использованию
1010
+ ```ts
1011
+ await resolver.enforce('ticket.buy', {
1012
+ user: { age: 25, ticketsCount: 1 },
1013
+ env: { time: { hour: 18 } },
1014
+ });
1015
+ ```
1016
+ If allowed — the code continues execution.
1017
+ If denied — an `AbilityError` exception is thrown.
1128
1018
 
1129
- ### Именование экшенов
1019
+ **Checking Permissions Without Exceptions (resolve)**
1130
1020
 
1131
- Используйте точечную нотацию для иерархии действий:
1021
+ `resolve` returns a result object:
1132
1022
 
1133
- - `order.create` - создание заказа
1134
- - `order.update` - обновление заказа
1135
- - `order.status.update` - обновление статуса заказа
1023
+ ```ts
1024
+ const result = await resolver.resolve('ticket.buy', {
1025
+ user: { age: 25, ticketsCount: 1 },
1026
+ env: { time: { hour: 18 } },
1027
+ });
1136
1028
 
1137
- ### Структура данных
1029
+ if (result.isAllowed()) {
1030
+ console.log('Purchase allowed');
1031
+ } else {
1032
+ console.log('Purchase denied');
1033
+ }
1034
+ ```
1138
1035
 
1139
- Старайтесь группировать связанные данные под общими ключами:
1036
+ **Seller can only sell during working hours**
1140
1037
 
1141
1038
  ```ts
1142
- // Хорошо
1143
- {
1144
- user: { id: '123', roles: ['admin'] },
1145
- order: { status: 'new', amount: 1000 }
1146
- }
1039
+ await resolver.enforce('ticket.sell', {
1040
+ user: { role: 'seller' },
1041
+ env: { time: { hour: 15 } },
1042
+ ticket: { status: 'available' },
1043
+ });
1044
+ ```
1147
1045
 
1148
- // Плохо
1149
- {
1150
- userId: '123',
1151
- userRoles: ['admin'],
1152
- orderStatus: 'new',
1153
- orderAmount: 1000
1154
- }
1046
+ **Preparing Data for the Resolver**
1047
+
1048
+ In the examples above, constant objects are passed to the resolver:
1049
+
1050
+ ```ts
1051
+ resolver.enforce('ticket.buy', {
1052
+ user: { age: 25 },
1053
+ env: { time: { hour: 18 } },
1054
+ });
1155
1055
  ```
1156
1056
 
1157
- ### Проектирование политик
1057
+ This is done for clarity. In a real application, the data for the resolver should be built dynamically — from the sources available to your server.
1058
+
1059
+ **User** (`user`) is usually taken from:
1060
+
1061
+ - JWT token
1062
+ - session
1063
+ - database
1064
+ - authorization middleware
1065
+
1066
+ Example:
1067
+
1068
+ ```ts
1069
+ const user = await db.users.findById(session.userId);
1070
+ ```
1071
+
1072
+ **Environment** (`env`)
1073
+
1074
+ These are any external parameters that can affect access:
1075
+
1076
+ - current server time
1077
+ - time zone
1078
+ - IP address
1079
+ - request headers
1080
+ - system configuration
1158
1081
 
1159
- - Используйте `deny` для запрещающих политик, `permit` для разрешающих
1160
- - Комбинируйте простые правила для сложной логики
1161
- - Давайте понятные имена правилам и политикам для упрощения отладки
1082
+ Example:
1162
1083
 
1163
- ## Отладка политик
1084
+ ```ts
1085
+ const env = {
1086
+ time: {
1087
+ hour: new Date().getHours(),
1088
+ },
1089
+ ip: req.ip,
1090
+ };
1091
+ ```
1092
+
1093
+ **Resource** (e.g., `ticket`)
1164
1094
 
1165
- Используйте `resolveWithExplain()` для получения детальной информации о процессе проверки:
1095
+ If the action is associated with a specific object, it also needs to be loaded:
1166
1096
 
1167
1097
  ```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
1098
+ const ticket = await db.tickets.findById(req.params.ticketId);
1176
1099
  ```
1177
1100
 
1101
+ **Context**
1102
+
1103
+ Context is the object that you pass to `resolve` or `enforce`.
1104
+ It contains **all the data** that policies might need:
1105
+
1106
+ - `user` — data about the current user
1107
+ - `env` — environment data (time, IP, geography, system settings)
1108
+ - `resource` or `ticket` — data about the entity on which the action is performed
1109
+ - any other objects that you use in DSL
1110
+
1111
+ **It is important to understand:**
1112
+
1113
+ > Context is formed for a specific action and specific policies. It does not need to be stored in advance — you gather it dynamically before calling the resolver.
1114
+
1115
+ ## Performance
1116
+
1117
+ The tests used policies with 10 conditions, nested fields, and environment.
1118
+
1119
+ **Tinybench** ([https://github.com/tinylibs/tinybench](https://github.com/tinylibs/tinybench))
1120
+
1121
+ | # | Task name | Latency avg (ns) | Latency med (ns) | Throughput avg (ops/s) | Throughput med (ops/s) | Samples |
1122
+ |---|-----------------------------------------|------------------------|------------------------|--------------------------|--------------------------|---------|
1123
+ | 0 | resolve() — no cache (heavy rules) | 646317 ± 0.32% | 632319 ± 8446.0 | 1555 ± 0.21% | 1581 ± 21 | 3095 |
1124
+ | 1 | resolve() — cold cache (heavy rules) | 636363 ± 0.38% | 623092 ± 7885.0 | 1581 ± 0.21% | 1605 ± 20 | 3143 |
1125
+ | 2 | resolve() — warm cache (heavy rules) | 631328 ± 0.26% | 621152 ± 6562.5 | 1590 ± 0.17% | 1610 ± 17 | 3168 |
1126
+
1127
+ ```
1128
+ Latency (ns)
1129
+ 650k | ███████████████████████████████████████ resolve() — no cache
1130
+ 640k | █████████████████████████████████████ resolve() — cold cache
1131
+ 630k | ████████████████████████████████████ resolve() — warm cache
1132
+ --------------------------------------------------------------
1133
+ no cache cold cache warm cache
1134
+ ```
1135
+
1136
+ ```
1137
+ Throughput (ops/s)
1138
+ 1600 | ███████████████████████████████████████ resolve() — warm cache
1139
+ 1590 | ██████████████████████████████████████ resolve() — cold cache
1140
+ 1580 | █████████████████████████████████████ resolve() — no cache
1141
+ --------------------------------------------------------------
1142
+ no cache cold cache warm cache
1143
+ ```
1178
1144
 
1179
- ## Лицензия
1145
+ ## License
1180
1146
 
1181
- Этот проект лицензирован под лицензией MIT. Подробности в файле [LICENSE](LICENSE).
1147
+ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.