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