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