@via-profit/ability 2.1.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +129 -0
- package/CONTRIBUTING.md +14 -0
- package/LICENSE +21 -0
- package/README.md +1058 -319
- package/SECURITY.md +33 -0
- package/dist/cache/AbilityCacheAdapter.d.ts +8 -0
- package/dist/cache/AbilityInMemoryCache.d.ts +12 -0
- package/dist/core/AbilityCondition.d.ts +21 -0
- package/dist/core/AbilityExplain.d.ts +27 -0
- package/dist/core/AbilityParser.d.ts +61 -0
- package/dist/core/AbilityPolicy.d.ts +84 -0
- package/dist/core/AbilityResolver.d.ts +35 -0
- package/dist/core/AbilityResult.d.ts +27 -0
- package/dist/core/AbilityRule.d.ts +77 -0
- package/dist/{AbilityRuleSet.d.ts → core/AbilityRuleSet.d.ts} +14 -11
- package/dist/index.d.ts +19 -11
- package/dist/index.js +2097 -303
- package/dist/parsers/dsl/AbilityDSLLexer.d.ts +24 -0
- package/dist/parsers/dsl/AbilityDSLParser.d.ts +86 -0
- package/dist/parsers/dsl/AbilityDSLSyntaxError.d.ts +13 -0
- package/dist/parsers/dsl/AbilityDSLToken.d.ts +55 -0
- package/dist/parsers/json/AbilityJSONParser.d.ts +22 -0
- package/package.json +15 -4
- package/assets/ability-01.drawio.png +0 -0
- package/build/playground.js +0 -456
- package/build/playground.js.map +0 -1
- package/dist/AbilityCondition.d.ts +0 -16
- package/dist/AbilityParser.d.ts +0 -18
- package/dist/AbilityPolicy.d.ts +0 -65
- package/dist/AbilityResolver.d.ts +0 -30
- package/dist/AbilityRule.d.ts +0 -70
- /package/dist/{AbilityCode.d.ts → core/AbilityCode.d.ts} +0 -0
- /package/dist/{AbilityCompare.d.ts → core/AbilityCompare.d.ts} +0 -0
- /package/dist/{AbilityError.d.ts → core/AbilityError.d.ts} +0 -0
- /package/dist/{AbilityMatch.d.ts → core/AbilityMatch.d.ts} +0 -0
- /package/dist/{AbilityPolicyEffect.d.ts → core/AbilityPolicyEffect.d.ts} +0 -0
package/README.md
CHANGED
|
@@ -2,437 +2,1176 @@
|
|
|
2
2
|
|
|
3
3
|
> Набор сервисов, частично реализующих
|
|
4
4
|
> принцип [Attribute Based Access Control](https://en.wikipedia.org/wiki/Attribute-based_access_control)
|
|
5
|
+
> Пакет позволяет описывать правила, объединять их в группы, формировать политики и применять их к данным для определения разрешений.
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
## Для чего
|
|
8
|
+
|
|
9
|
+
Пакет задуман как **лёгкая и предельно простая альтернатива** тяжёлым системам управления доступом.
|
|
10
|
+
Без сложных конфигураций, без зависимостей — только минимальный набор инструментов, который позволяет описывать правила и политики в максимально простом DSL.
|
|
7
11
|
|
|
8
12
|
## Содержание
|
|
9
13
|
|
|
10
|
-
|
|
11
|
-
- [
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
- [
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
- [
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
- [
|
|
21
|
-
|
|
22
|
-
- [Проверка политики](#policy-check)
|
|
23
|
-
- [Управление политиками](#policy-management)
|
|
14
|
+
- [Быстрый старт](#быстрый-старт)
|
|
15
|
+
- [Основные положения](#основные-положения)
|
|
16
|
+
- [DSL](#dsl)
|
|
17
|
+
- [Объединение политик](#объединение-политик)
|
|
18
|
+
- [Environment политик](#environment-политик)
|
|
19
|
+
- [Генератор типов для TypeScript](#генератор-типов-для-typescript)
|
|
20
|
+
- [Отладка политик](#отладка-политик)
|
|
21
|
+
- [Решение проблем](#решение-проблем)
|
|
22
|
+
- [Рекомендации по проектированию](#рекомендации-по-проектированию)
|
|
23
|
+
- [Примеры](#примеры)
|
|
24
|
+
- [Производительность](#производительность)
|
|
25
|
+
- [Api-Reference](./docs/ru/api.md)
|
|
24
26
|
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
## Быстрый старт
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
###
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
- **`AbilityCondition`** — методы вычисления (`equal`, `not_equal`, `more_than`, `less_than`, `in`, `not_in` и др.)
|
|
39
|
-
- **`AbilityPolicyEffect`** — эффекты политики (`deny`, `permit`)
|
|
40
|
-
- **`AbilityParser`** — парсер конфигурационных правил (JSON)
|
|
41
|
-
- **`AbilityError`** — инстанс ошибок
|
|
42
|
-
|
|
43
|
-
### Основные принципы <a name="principles"></a>
|
|
44
|
-
|
|
45
|
-
Работа сервиса основана на формировании **правил**, объединении их в **политики** и проверке доступа с их помощью.
|
|
46
|
-
|
|
47
|
-
Пример: необходимо **запретить доступ** пользователям, связанным с отделом менеджеров, **за исключением администраторов**.
|
|
48
|
-
|
|
49
|
-
- Менеджеры — если их отдел `managers` или есть роль `manager`
|
|
50
|
-
- Администраторы — пользователи с ролью `administrator`
|
|
51
|
-
|
|
52
|
-
Структура политики:
|
|
53
|
-
|
|
54
|
-

|
|
55
|
-
|
|
56
|
-
JSON-конфигурация:
|
|
57
|
-
|
|
58
|
-
```json
|
|
59
|
-
{
|
|
60
|
-
"name": "Запрет доступа для менеджеров (исключение: администраторы)",
|
|
61
|
-
"compareMethod": "and",
|
|
62
|
-
"action": "order.update",
|
|
63
|
-
"effect": "deny",
|
|
64
|
-
"ruleSet": [
|
|
65
|
-
{
|
|
66
|
-
"name": "Менеджеры",
|
|
67
|
-
"compareMethod": "or",
|
|
68
|
-
"rules": [
|
|
69
|
-
{
|
|
70
|
-
"name": "Отдел managers",
|
|
71
|
-
"subject": "user.department",
|
|
72
|
-
"resource": "managers",
|
|
73
|
-
"condition": "in"
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
"name": "Роль manager",
|
|
77
|
-
"subject": "user.roles",
|
|
78
|
-
"resource": "manager",
|
|
79
|
-
"condition": "in"
|
|
80
|
-
}
|
|
81
|
-
]
|
|
82
|
-
},
|
|
83
|
-
{
|
|
84
|
-
"name": "Не администраторы",
|
|
85
|
-
"compareMethod": "and",
|
|
86
|
-
"rules": [
|
|
87
|
-
{
|
|
88
|
-
"name": "Нет роли administrator",
|
|
89
|
-
"subject": "user.roles",
|
|
90
|
-
"resource": "administrator",
|
|
91
|
-
"condition": "not in"
|
|
92
|
-
}
|
|
93
|
-
]
|
|
94
|
-
}
|
|
95
|
-
]
|
|
96
|
-
}
|
|
30
|
+
Установить пакет, написать DSL, вызвать парсер, запустить резолвер.
|
|
31
|
+
|
|
32
|
+
### Установка
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install @via-profit/ability
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
yarn add @via-profit/ability
|
|
97
40
|
```
|
|
98
41
|
|
|
99
|
-
|
|
42
|
+
```bash
|
|
43
|
+
pnpm add @via-profit/ability
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
### Пример: запретить доступ к `passwordHash` всем, кроме владельца
|
|
48
|
+
|
|
49
|
+
Допустим, у нас есть пользовательские данные:
|
|
100
50
|
|
|
101
51
|
```ts
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
52
|
+
const user = {
|
|
53
|
+
id: '1',
|
|
54
|
+
login: 'user-001',
|
|
55
|
+
passwordHash: '...',
|
|
56
|
+
};
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Нужно запретить чтение `passwordHash` всем, кроме самого пользователя.
|
|
60
|
+
|
|
61
|
+
#### DSL‑политика
|
|
62
|
+
|
|
63
|
+
На языке политик это выглядит так:
|
|
64
|
+
|
|
109
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. Используйте встроенный кэш только в случаях, если ваши политики неимоверно сложны и содержат большое количество правил
|
|
110
114
|
|
|
111
115
|
---
|
|
112
116
|
|
|
113
|
-
##
|
|
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
|
+
Пример:
|
|
114
143
|
|
|
115
|
-
|
|
116
|
-
|
|
144
|
+
```dsl
|
|
145
|
+
permit permission.order.update if any:
|
|
146
|
+
all of:
|
|
147
|
+
user.roles contains 'admin'
|
|
148
|
+
user.token is not null
|
|
117
149
|
|
|
118
|
-
|
|
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)
|
|
119
162
|
|
|
120
|
-
|
|
163
|
+
Ключ разрешения записываются в `dot notation` виде, но поддерживают возможность использования wildcard шаблонов при
|
|
164
|
+
помощи символа `*`. Это позволяет группировать ключи, а так же переопределять политики с похожими ключами.
|
|
121
165
|
|
|
122
|
-
|
|
166
|
+
Если под ключ подходит несколько политик, **выполняются все**. Итог определяется **последней совпавшей политикой**:
|
|
123
167
|
|
|
124
|
-
- **id** - `string` Уникальный идентификатор.
|
|
125
|
-
- **name** - `string` Название правила.
|
|
126
|
-
- **condition** - `AbilityCondition` Определяет условия сравнения переданных данных
|
|
127
|
-
- **subject** - `string` Dot notation путь в проверяемом субъекте, например: `user.name`.
|
|
128
|
-
- **resource** - `string | number | boolean | (string | number)[]` Dot notation путь в проверяемом ресурсе, например:
|
|
129
|
-
`user.name` или значение, которое может быть строкой, числом, булеан значением или массивом строк или чисел.
|
|
130
168
|
|
|
131
|
-
|
|
169
|
+
**Пример использования шаблонов**
|
|
132
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**
|
|
133
183
|
```ts
|
|
134
|
-
import {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
+
Группы могут иметь аннотации:
|
|
143
279
|
|
|
280
|
+
```dsl
|
|
281
|
+
# @name developer group
|
|
282
|
+
any of:
|
|
283
|
+
user.roles contains 'developer'
|
|
144
284
|
```
|
|
145
285
|
|
|
146
|
-
|
|
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
|
+
**Примеры:**
|
|
147
461
|
|
|
148
462
|
```ts
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
463
|
+
type Environment = {
|
|
464
|
+
time: {
|
|
465
|
+
hour: number;
|
|
466
|
+
};
|
|
467
|
+
ip: string;
|
|
468
|
+
geo: {
|
|
469
|
+
country: string;
|
|
470
|
+
};
|
|
471
|
+
};
|
|
472
|
+
```
|
|
158
473
|
|
|
474
|
+
Environment передаётся в `resolve()` и `enforce()` как третий аргумент:
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
await resolver.resolve('order.update', resource, environment);
|
|
478
|
+
await resolver.enforce('order.update', resource, environment);
|
|
159
479
|
```
|
|
160
480
|
|
|
161
|
-
###
|
|
481
|
+
### Использование environment в правилах
|
|
482
|
+
|
|
483
|
+
В политике можно ссылаться на environment через путь `env.*`.
|
|
162
484
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
+
Пример:
|
|
166
503
|
|
|
167
504
|
```ts
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
"name": "Пользователь из отдела managers",
|
|
173
|
-
"subject": "user.department",
|
|
174
|
-
"resource": "managers",
|
|
175
|
-
"condition": "="
|
|
176
|
-
});
|
|
505
|
+
subject: "env.geo.country"
|
|
506
|
+
resource: "user.country"
|
|
507
|
+
condition: "equal"
|
|
508
|
+
```
|
|
177
509
|
|
|
178
|
-
|
|
179
|
-
user: {
|
|
180
|
-
department: 'managers',
|
|
181
|
-
},
|
|
182
|
-
});
|
|
510
|
+
### Environment в TypeScript
|
|
183
511
|
|
|
184
|
-
|
|
512
|
+
Тип Environment задаётся на уровне `AbilityResolver`:
|
|
185
513
|
|
|
514
|
+
```ts
|
|
515
|
+
const resolver = new AbilityResolver<Resources, Environment>(policies);
|
|
186
516
|
```
|
|
187
517
|
|
|
188
|
-
|
|
518
|
+
Это позволяет:
|
|
189
519
|
|
|
190
|
-
|
|
520
|
+
- получать автодополнение в IDE,
|
|
521
|
+
- проверять корректность путей `env.*`,
|
|
522
|
+
- избегать ошибок при передаче environment.
|
|
191
523
|
|
|
192
|
-
|
|
193
|
-
правила в группе и вернуть лишь один результат.
|
|
524
|
+
> Если правило использует `env.*`, но environment не передан, то значение `env.*` будет `undefined`, и сравнение будет выполнено так, как если бы environment не было вовсе
|
|
194
525
|
|
|
195
|
-
Создавая группу следует указывать метод сравнения (`compareMethod`), который необходим для вычисления значения всей
|
|
196
|
-
группы при проверке правил.
|
|
197
526
|
|
|
198
|
-
При создании необходимо указать следующие параметры:
|
|
199
527
|
|
|
200
|
-
|
|
201
|
-
- **name** - `string` Название группы.
|
|
202
|
-
- **compareMethod** - `AbilityCompare` Способ сравнения правил в группе (`or` или `and`).
|
|
528
|
+
## Генератор типов для TypeScript
|
|
203
529
|
|
|
204
|
-
|
|
530
|
+
`AbilityParser.generateTypeDefs()` генерирует типы для TypeScript на основе политик, что позволяет не беспокоиться о расхождении между типами и данными в политиках.
|
|
205
531
|
|
|
206
|
-
|
|
207
|
-
- **`and`** - Результат всей группы примет значение `match`, если все правила вернули `match`.
|
|
532
|
+
**Пример использования**
|
|
208
533
|
|
|
209
|
-
|
|
534
|
+
Сначала необходимо подготовить массив политик. Политики можно хранить в DSL или в JSON и парсить их в массив готовых политик. В данном примере, для наглядности, политики хранятся в DSL.
|
|
210
535
|
|
|
211
|
-
|
|
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:
|
|
212
544
|
|
|
213
|
-
|
|
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
|
+
```
|
|
214
555
|
|
|
215
556
|
```ts
|
|
216
|
-
|
|
557
|
+
// scripts/generate-types.ts
|
|
558
|
+
import { writeFileSync } from 'node:fs';
|
|
559
|
+
import { AbilityParser } from '@via-profit/ability';
|
|
560
|
+
import policies from './policies.json';
|
|
217
561
|
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
});
|
|
562
|
+
const typedefs = AbilityParser.generateTypeDefs(policies);
|
|
563
|
+
|
|
564
|
+
writeFileSync('./src/ability/types.generated.ts', typedefs, 'utf8');
|
|
565
|
+
```
|
|
223
566
|
|
|
224
|
-
|
|
225
|
-
ruleSet.addRules([
|
|
226
|
-
new AbilityRule(...),
|
|
227
|
-
new AbilityRule(...),
|
|
228
|
-
]);
|
|
567
|
+
**Сгенерированный файл (пример)**
|
|
229
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
|
+
};
|
|
230
584
|
```
|
|
231
585
|
|
|
232
|
-
|
|
586
|
+
**Использование в коде**
|
|
233
587
|
|
|
234
588
|
```ts
|
|
235
|
-
import {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
'rules': [
|
|
242
|
-
{
|
|
243
|
-
'id': '<rule-id>',
|
|
244
|
-
'name': 'Пользователь из отдела managers',
|
|
245
|
-
'subject': 'user.department',
|
|
246
|
-
'resource': 'managers',
|
|
247
|
-
'condition': '=',
|
|
248
|
-
},
|
|
249
|
-
],
|
|
250
|
-
});
|
|
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
|
+
);
|
|
251
595
|
|
|
596
|
+
await resolver.enforce('order.update', {
|
|
597
|
+
user: { id: 'u1' },
|
|
598
|
+
order: { ownerId: 'u1' },
|
|
599
|
+
});
|
|
252
600
|
```
|
|
253
601
|
|
|
254
|
-
|
|
602
|
+
## Отладка политик
|
|
255
603
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
604
|
+
### Объяснения
|
|
605
|
+
|
|
606
|
+
Для упрощения отладки политик применяется специальный класс `AbilityResult`, который уже включён в итоговый результат вычислений. `AbilityResult` инкапсулирует итог применения всех подходящих политик к ключу разрешений и ресурсу.
|
|
607
|
+
|
|
608
|
+
`AbilityResult` содержит:
|
|
609
|
+
|
|
610
|
+
- список проверенных политик,
|
|
611
|
+
- методы для определения итогового эффекта,
|
|
612
|
+
- методы для получения объяснений в текстовом представлении.
|
|
613
|
+
|
|
614
|
+
Пример:
|
|
259
615
|
|
|
260
616
|
```ts
|
|
261
|
-
|
|
617
|
+
const result = await resolver.resolve('order.update', resource);
|
|
262
618
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
compareMethod: AbilityCompare.and,
|
|
267
|
-
}).addRules([
|
|
268
|
-
new AbilityRule(...),
|
|
269
|
-
new AbilityRule(...),
|
|
270
|
-
]);
|
|
619
|
+
if (result.isDenied()) {
|
|
620
|
+
console.log('Access denied');
|
|
621
|
+
}
|
|
271
622
|
|
|
272
|
-
const
|
|
623
|
+
const explanations = result.explain(); // AbilityExplain
|
|
273
624
|
|
|
274
|
-
|
|
625
|
+
// console.log(explanations.toString());
|
|
275
626
|
```
|
|
276
627
|
|
|
277
|
-
|
|
628
|
+
### AbilityExplain
|
|
278
629
|
|
|
279
|
-
|
|
630
|
+
`AbilityExplain` и связанные классы (`AbilityExplainPolicy`, `AbilityExplainRuleSet`, `AbilityExplainRule`) позволяют получить человекочитаемое объяснение:
|
|
280
631
|
|
|
281
|
-
|
|
282
|
-
|
|
632
|
+
- какая политика сработала,
|
|
633
|
+
- какие группы правил совпали,
|
|
634
|
+
- какие правила не прошли,
|
|
635
|
+
- какой эффект был применён.
|
|
283
636
|
|
|
284
|
-
|
|
637
|
+
Пример использования:
|
|
285
638
|
|
|
286
|
-
|
|
639
|
+
```ts
|
|
640
|
+
const result = await resolver.resolve('order.update', resource);
|
|
641
|
+
const explanations = result.explain();
|
|
287
642
|
|
|
288
|
-
|
|
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
|
+
### Формат вывода
|
|
289
658
|
|
|
290
|
-
|
|
291
|
-
- **name** - `string` Название политики.
|
|
292
|
-
- **action** - `string` Ключ политики, в формате Dot notation, определяющий схожесть политик. В названии может
|
|
293
|
-
применяться символ звездочки (`*`). Политики с одинаковым экшеном обрабатываются вместе как группа политик. Экшен
|
|
294
|
-
`users.account` не считается похожим с экшеном `users.account.login`, но в это же время `users.account.*` равен экшену
|
|
295
|
-
`users.account.login` (из-за использования звездочки).
|
|
296
|
-
- **compareMethod** - `AbilityCompare` Метод сравнения групп правил, входящих в политику (`or` или `and`)
|
|
297
|
-
- **effect** - `AbilityPolicyEffect` Определяет итоговый результат всех вычислений (`permit` или `deny`). В слчае
|
|
298
|
-
использования класса `AbilityResolver` (метод `enforce`) последний выкинет исключение `AbilityError`, если политика
|
|
299
|
-
вернёт `deny`. Текст сообщения `AbilityError` будет соответствовать названию сработавшей политики. В остальных случаях
|
|
300
|
-
ничего не произойдет.
|
|
301
|
-
- **ruleSet** - `AbilityRuleSet[]` Массив групп (см. [Группы правил](#rule-sets))
|
|
659
|
+
В настоящий момент поддерживается только один формат вывода - текстовый.
|
|
302
660
|
|
|
303
|
-
|
|
304
|
-
необходимо ограничить какой-либо доступ, например, пользователю с недостаточными правами, то следует создавать политику
|
|
305
|
-
с эффектом `deny`.
|
|
661
|
+
Вывод строится по принципу: <policy | ruleSet | rule > <название> <is match | is mismatch>
|
|
306
662
|
|
|
307
|
-
|
|
663
|
+
|
|
664
|
+
## Решение проблем
|
|
665
|
+
|
|
666
|
+
### Модель принятия решений (Default Deny)
|
|
667
|
+
|
|
668
|
+
> Почему политика `deny` не превращается в `permit`, если её условия не выполнены?
|
|
669
|
+
|
|
670
|
+
Рассмотрим политику, которая **запрещает** доступ пользователю с возрастом 16 лет:
|
|
308
671
|
|
|
309
672
|
```ts
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
id: '<rule-id>',
|
|
321
|
-
name: 'Пользователь является владельцем заказа',
|
|
322
|
-
subject: 'user.id',
|
|
323
|
-
resource: 'order.owner',
|
|
324
|
-
condition: AbilityCondition.equal
|
|
325
|
-
})
|
|
326
|
-
]
|
|
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 },
|
|
327
683
|
});
|
|
328
684
|
|
|
685
|
+
console.log(result.isDenied()); // true ✔
|
|
686
|
+
console.log(result.isAllowed()); // false ✔
|
|
329
687
|
```
|
|
330
688
|
|
|
331
|
-
|
|
689
|
+
В этом случае всё очевидно:
|
|
690
|
+
условие выполнено → политика совпала → эффект `deny` → доступ запрещён.
|
|
691
|
+
|
|
692
|
+
**Что происходит, если условия `не выполнены`?**
|
|
332
693
|
|
|
333
694
|
```ts
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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);
|
|
354
792
|
}
|
|
355
|
-
|
|
793
|
+
}
|
|
356
794
|
}
|
|
357
|
-
]
|
|
358
|
-
});
|
|
359
795
|
|
|
796
|
+
check();
|
|
797
|
+
|
|
798
|
+
return () => {
|
|
799
|
+
cancelled = true;
|
|
800
|
+
};
|
|
801
|
+
}, [resolver, permission, resource]);
|
|
802
|
+
|
|
803
|
+
return allowed;
|
|
804
|
+
}
|
|
360
805
|
```
|
|
361
806
|
|
|
362
|
-
|
|
807
|
+
**Использование в компоненте**
|
|
363
808
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
809
|
+
```tsx
|
|
810
|
+
function OrderUpdateButton({ order, user }) {
|
|
811
|
+
const allowed = useAbility(resolver, 'order.update', {
|
|
812
|
+
user,
|
|
813
|
+
order,
|
|
814
|
+
});
|
|
367
815
|
|
|
368
|
-
|
|
369
|
-
|
|
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 ====
|
|
370
912
|
|
|
371
|
-
|
|
913
|
+
U1 --> U1A[Покупать билеты]
|
|
914
|
+
U1A -->|ticketsCount < 6| U1OK[Разрешено]
|
|
915
|
+
U1A -->|ticketsCount ≥ 6| U1DENY[Запрещено]
|
|
372
916
|
|
|
373
|
-
|
|
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
|
|
374
932
|
|
|
375
|
-
const is = match.isEqual(AbilityMatch.match);
|
|
376
933
|
```
|
|
377
934
|
|
|
378
|
-
|
|
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'
|
|
379
969
|
|
|
380
|
-
## Управление политиками <a name="policy-management"></a>
|
|
381
970
|
|
|
382
|
-
|
|
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
|
|
383
977
|
|
|
384
|
-
В случае, если вам необходимо запустить лишь разовую проверку данных, то данный раздел можно опустить.
|
|
385
978
|
|
|
386
|
-
|
|
979
|
+
############################################################
|
|
980
|
+
# @name Manager can do everything seller can
|
|
981
|
+
permit permission.ticket.sell if all:
|
|
982
|
+
user.role is equals 'manager'
|
|
387
983
|
|
|
388
|
-
|
|
389
|
-
|
|
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
|
+
**Подготовка политик**
|
|
390
1008
|
|
|
391
1009
|
```ts
|
|
392
|
-
import {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const policies
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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 } },
|
|
404
1035
|
});
|
|
405
1036
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
})
|
|
411
|
-
.isDeny();
|
|
1037
|
+
```
|
|
1038
|
+
Если разрешено — код продолжит выполнение.
|
|
1039
|
+
Если запрещено — будет выброшено исключение `AbilityError`.
|
|
1040
|
+
|
|
412
1041
|
|
|
413
|
-
|
|
414
|
-
|
|
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('Покупка запрещена');
|
|
415
1057
|
}
|
|
416
1058
|
|
|
1059
|
+
```
|
|
417
1060
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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,
|
|
431
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 |
|
|
432
1154
|
|
|
433
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
|
+
|
|
434
1174
|
|
|
435
|
-
|
|
436
|
-
по указанному экшену, а самое главное, что при помощи типа `Resources`, который необходимо формировать
|
|
437
|
-
вручную, **TypeScript** подскажет какие именно данные следует передать вторым аргументом (ресурс)._
|
|
1175
|
+
## Лицензия
|
|
438
1176
|
|
|
1177
|
+
Этот проект лицензирован под лицензией MIT. Подробности в файле [LICENSE](LICENSE).
|