@via-profit/ability 3.5.4 → 3.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -30
- package/dist/index.d.ts +435 -201
- package/dist/index.js +1871 -852
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,25 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
constructor(code) {
|
|
6
|
-
this._code = code;
|
|
7
|
-
}
|
|
8
|
-
get code() {
|
|
9
|
-
return this._code;
|
|
10
|
-
}
|
|
11
|
-
isEqual(compareWith) {
|
|
12
|
-
return compareWith !== null && this.code === compareWith.code;
|
|
13
|
-
}
|
|
14
|
-
isNotEqual(compareWith) {
|
|
15
|
-
return !this.isEqual(compareWith);
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
class AbilityCompare extends AbilityCode {
|
|
20
|
-
static and = new AbilityCompare('and');
|
|
21
|
-
static or = new AbilityCompare('or');
|
|
3
|
+
function brand$4(code) {
|
|
4
|
+
return code;
|
|
22
5
|
}
|
|
6
|
+
const AbilityCompare = {
|
|
7
|
+
or: brand$4('or'),
|
|
8
|
+
and: brand$4('and'),
|
|
9
|
+
};
|
|
23
10
|
|
|
24
11
|
class AbilityError extends Error {
|
|
25
12
|
constructor(message, options) {
|
|
@@ -40,111 +27,297 @@ class AbilityParserError extends Error {
|
|
|
40
27
|
}
|
|
41
28
|
}
|
|
42
29
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
not_in: this.not_in,
|
|
86
|
-
contains: this.contains,
|
|
87
|
-
not_contains: this.not_contains,
|
|
88
|
-
length_greater_than: this.length_greater_than,
|
|
89
|
-
length_equals: this.length_equals,
|
|
90
|
-
always: this.always,
|
|
91
|
-
never: this.never,
|
|
92
|
-
length_less_than: this.length_less_than,
|
|
93
|
-
};
|
|
94
|
-
const condition = map[literal];
|
|
95
|
-
if (!condition) {
|
|
96
|
-
throw new AbilityParserError(`Literal "${literal}" does not found in AbilityCondition class`);
|
|
97
|
-
}
|
|
98
|
-
return condition;
|
|
30
|
+
function brand$3(code) {
|
|
31
|
+
return code;
|
|
32
|
+
}
|
|
33
|
+
const AbilityCondition = {
|
|
34
|
+
equals: brand$3('='),
|
|
35
|
+
not_equals: brand$3('<>'),
|
|
36
|
+
greater_than: brand$3('>'),
|
|
37
|
+
less_than: brand$3('<'),
|
|
38
|
+
less_or_equal: brand$3('<='),
|
|
39
|
+
greater_or_equal: brand$3('>='),
|
|
40
|
+
in: brand$3('in'),
|
|
41
|
+
not_in: brand$3('not in'),
|
|
42
|
+
contains: brand$3('contains'),
|
|
43
|
+
not_contains: brand$3('not contains'),
|
|
44
|
+
length_greater_than: brand$3('length greater than'),
|
|
45
|
+
length_less_than: brand$3('length less than'),
|
|
46
|
+
length_equals: brand$3('length equals'),
|
|
47
|
+
always: brand$3('always'),
|
|
48
|
+
never: brand$3('never'),
|
|
49
|
+
};
|
|
50
|
+
function fromLiteral(literal) {
|
|
51
|
+
const map = {
|
|
52
|
+
equals: AbilityCondition.equals,
|
|
53
|
+
not_equals: AbilityCondition.not_equals,
|
|
54
|
+
greater_than: AbilityCondition.greater_than,
|
|
55
|
+
less_than: AbilityCondition.less_than,
|
|
56
|
+
less_or_equal: AbilityCondition.less_or_equal,
|
|
57
|
+
greater_or_equal: AbilityCondition.greater_or_equal,
|
|
58
|
+
in: AbilityCondition.in,
|
|
59
|
+
not_in: AbilityCondition.not_in,
|
|
60
|
+
contains: AbilityCondition.contains,
|
|
61
|
+
not_contains: AbilityCondition.not_contains,
|
|
62
|
+
length_greater_than: AbilityCondition.length_greater_than,
|
|
63
|
+
length_less_than: AbilityCondition.length_less_than,
|
|
64
|
+
length_equals: AbilityCondition.length_equals,
|
|
65
|
+
always: AbilityCondition.always,
|
|
66
|
+
never: AbilityCondition.never,
|
|
67
|
+
};
|
|
68
|
+
const value = map[literal];
|
|
69
|
+
if (!value) {
|
|
70
|
+
const expected = Object.keys(map).join(', ');
|
|
71
|
+
throw new AbilityParserError(`Literal "${literal}" does not found in AbilityCondition. Expected one of: ${expected}`);
|
|
99
72
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
function toLiteral(cond) {
|
|
76
|
+
switch (cond) {
|
|
77
|
+
case AbilityCondition.equals:
|
|
78
|
+
return 'equals';
|
|
79
|
+
case AbilityCondition.not_equals:
|
|
80
|
+
return 'not_equals';
|
|
81
|
+
case AbilityCondition.greater_than:
|
|
82
|
+
return 'greater_than';
|
|
83
|
+
case AbilityCondition.less_than:
|
|
84
|
+
return 'less_than';
|
|
85
|
+
case AbilityCondition.less_or_equal:
|
|
86
|
+
return 'less_or_equal';
|
|
87
|
+
case AbilityCondition.greater_or_equal:
|
|
88
|
+
return 'greater_or_equal';
|
|
89
|
+
case AbilityCondition.in:
|
|
90
|
+
return 'in';
|
|
91
|
+
case AbilityCondition.not_in:
|
|
92
|
+
return 'not_in';
|
|
93
|
+
case AbilityCondition.contains:
|
|
94
|
+
return 'contains';
|
|
95
|
+
case AbilityCondition.not_contains:
|
|
96
|
+
return 'not_contains';
|
|
97
|
+
case AbilityCondition.length_greater_than:
|
|
98
|
+
return 'length_greater_than';
|
|
99
|
+
case AbilityCondition.length_less_than:
|
|
100
|
+
return 'length_less_than';
|
|
101
|
+
case AbilityCondition.length_equals:
|
|
102
|
+
return 'length_equals';
|
|
103
|
+
case AbilityCondition.always:
|
|
104
|
+
return 'always';
|
|
105
|
+
case AbilityCondition.never:
|
|
106
|
+
return 'never';
|
|
107
|
+
default:
|
|
108
|
+
return 'never';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function isConditionEqual(a, b) {
|
|
112
|
+
return a !== null && b !== null && a === b;
|
|
113
|
+
}
|
|
114
|
+
function isConditionNotEqual(a, b) {
|
|
115
|
+
return !isConditionEqual(a, b);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function brand$2(code) {
|
|
119
|
+
return code;
|
|
120
|
+
}
|
|
121
|
+
const AbilityMatch = {
|
|
122
|
+
pending: brand$2('pending'),
|
|
123
|
+
match: brand$2('match'),
|
|
124
|
+
mismatch: brand$2('mismatch'),
|
|
125
|
+
exceptMismatch: brand$2('except-mismatch'),
|
|
126
|
+
disabled: brand$2('disabled'),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
class AbilityExplain {
|
|
130
|
+
type;
|
|
131
|
+
children;
|
|
132
|
+
name;
|
|
133
|
+
match;
|
|
134
|
+
constructor(config, children = []) {
|
|
135
|
+
this.type = config.type;
|
|
136
|
+
this.children = children;
|
|
137
|
+
this.name = config.name;
|
|
138
|
+
this.match = config.match;
|
|
139
|
+
}
|
|
140
|
+
toString(indent = 0) {
|
|
141
|
+
const pad = ' '.repeat(indent);
|
|
142
|
+
const mark = this.match === AbilityMatch.match ? '✓' : '✗';
|
|
143
|
+
let out = '';
|
|
144
|
+
if (this.type === 'policy') {
|
|
145
|
+
out += '\n';
|
|
134
146
|
}
|
|
147
|
+
out += `${pad}${mark} ${this.type} «${this.name}» is ${this.match}`;
|
|
148
|
+
this.children.forEach(child => {
|
|
149
|
+
out += '\n' + child.toString(indent + 1);
|
|
150
|
+
});
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
class AbilityExplainRule extends AbilityExplain {
|
|
155
|
+
constructor(rule) {
|
|
156
|
+
super({
|
|
157
|
+
type: 'rule',
|
|
158
|
+
match: rule.state,
|
|
159
|
+
name: rule.name,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
class AbilityExplainRuleSet extends AbilityExplain {
|
|
164
|
+
constructor(ruleSet) {
|
|
165
|
+
const children = ruleSet.rules.map(rule => new AbilityExplainRule(rule));
|
|
166
|
+
super({
|
|
167
|
+
type: 'ruleSet',
|
|
168
|
+
match: ruleSet.state,
|
|
169
|
+
name: ruleSet.name,
|
|
170
|
+
}, children);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
class AbilityExplainPolicy extends AbilityExplain {
|
|
174
|
+
constructor(policy) {
|
|
175
|
+
const children = policy.ruleSet.map(ruleSet => new AbilityExplainRuleSet(ruleSet));
|
|
176
|
+
super({
|
|
177
|
+
type: 'policy',
|
|
178
|
+
name: policy.priority > -1 ? `@priority ${policy.priority} ${policy.name}` : policy.name,
|
|
179
|
+
match: policy.matchState,
|
|
180
|
+
}, children);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
class AbilityResult {
|
|
185
|
+
effect;
|
|
186
|
+
strategy;
|
|
187
|
+
constructor(effect, strategy) {
|
|
188
|
+
this.effect = effect;
|
|
189
|
+
this.strategy = strategy;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Returns a list of explanations for each policy involved in the ability evaluation.
|
|
193
|
+
* Each item describes how a specific policy contributed to the final permission result.
|
|
194
|
+
*
|
|
195
|
+
* Useful for debugging, logging, or building UI tools that visualize permission logic.
|
|
196
|
+
*/
|
|
197
|
+
explain() {
|
|
198
|
+
return this.strategy.policies.map(policy => {
|
|
199
|
+
return new AbilityExplainPolicy(policy);
|
|
200
|
+
});
|
|
135
201
|
}
|
|
202
|
+
isAllowed = () => {
|
|
203
|
+
return this.strategy.isAllowed();
|
|
204
|
+
};
|
|
205
|
+
isDenied = () => {
|
|
206
|
+
return this.strategy.isDenied();
|
|
207
|
+
};
|
|
136
208
|
}
|
|
137
209
|
|
|
138
|
-
class
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
210
|
+
class AbilityResolver {
|
|
211
|
+
StrategyClass;
|
|
212
|
+
policyEntries;
|
|
213
|
+
constructor(
|
|
214
|
+
/**
|
|
215
|
+
* `Important!` The incorrect Resources type was intentionally passed to AbilityPolicy so that TypeScript could suggest the name of the permission and the structure of its resource in the parse method.
|
|
216
|
+
*/
|
|
217
|
+
policyOrListOfPolicies, strategy, options = {}) {
|
|
218
|
+
const policies = this.toArray(policyOrListOfPolicies);
|
|
219
|
+
const filtered = options.tags
|
|
220
|
+
? policies.filter(p => p.tags.some(tag => options.tags.includes(tag)))
|
|
221
|
+
: policies;
|
|
222
|
+
const sorted = [...filtered].sort((a, b) => b.priority - a.priority);
|
|
223
|
+
this.policyEntries = sorted.map(policy => ({
|
|
224
|
+
policy,
|
|
225
|
+
normalizedPermission: AbilityResolver.normalizePermission(policy.permission),
|
|
226
|
+
segments: AbilityResolver.normalizePermission(policy.permission).split('.'),
|
|
227
|
+
}));
|
|
228
|
+
this.StrategyClass = strategy;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Resolve policy for the resource and permission key
|
|
232
|
+
*
|
|
233
|
+
* @param permission - Permission key
|
|
234
|
+
* @param resource - Resource
|
|
235
|
+
* @param environment
|
|
236
|
+
*/
|
|
237
|
+
resolve(permission, resource, environment) {
|
|
238
|
+
const inputNormalized = AbilityResolver.normalizePermission(String(permission));
|
|
239
|
+
const inputSegments = inputNormalized.split('.');
|
|
240
|
+
const filteredPolicies = this.policyEntries
|
|
241
|
+
.filter(entry => AbilityResolver.matchPermissions(entry.segments, inputSegments))
|
|
242
|
+
.map(entry => entry.policy);
|
|
243
|
+
// 2. check the policies
|
|
244
|
+
for (const policy of filteredPolicies) {
|
|
245
|
+
if (policy.disabled) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const policyMatchState = policy.check(resource, environment);
|
|
249
|
+
if (policyMatchState === AbilityMatch.pending) {
|
|
250
|
+
throw new AbilityError(`The policy "${policy.name}" is still in a pending state. Make sure to call "check" to evaluate the policy before resolving permissions.`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// 3. Use strategy
|
|
254
|
+
const strategy = new this.StrategyClass(filteredPolicies);
|
|
255
|
+
const effect = strategy.evaluate();
|
|
256
|
+
return new AbilityResult(effect, strategy);
|
|
257
|
+
}
|
|
258
|
+
enforce(permission, resource, environment) {
|
|
259
|
+
const result = this.resolve(permission, resource, environment);
|
|
260
|
+
if (result.isDenied()) {
|
|
261
|
+
throw new AbilityError(`Permission denied`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* @deprecated - will be removed
|
|
266
|
+
*
|
|
267
|
+
* Check if the permission key is contained in another permission key
|
|
268
|
+
* @param permissionA - The first permission to check
|
|
269
|
+
* @param permissionB - The second permission to check
|
|
270
|
+
*/
|
|
271
|
+
static isInPermissionContain(permissionA, permissionB) {
|
|
272
|
+
const A = permissionA.split('.');
|
|
273
|
+
const B = permissionB.split('.');
|
|
274
|
+
const [longer, shorter] = A.length >= B.length ? [A, B] : [B, A];
|
|
275
|
+
return shorter.every((chunk, i) => {
|
|
276
|
+
return chunk === '*' || longer[i] === '*' || chunk === longer[i];
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
toArray(value) {
|
|
280
|
+
return [...(Array.isArray(value) ? value : [value])];
|
|
281
|
+
}
|
|
282
|
+
static normalizePermission(permission) {
|
|
283
|
+
return permission
|
|
284
|
+
.trim()
|
|
285
|
+
.replace(/^permission\./, '') // remove prefix
|
|
286
|
+
.replace(/\.+/g, '.') // collapse multiple dots
|
|
287
|
+
.toLowerCase(); // optional: make case-insensitive
|
|
288
|
+
}
|
|
289
|
+
static matchPermissions(policySegments, inputSegments) {
|
|
290
|
+
const maxLen = Math.max(policySegments.length, inputSegments.length);
|
|
291
|
+
for (let i = 0; i < maxLen; i++) {
|
|
292
|
+
const pSeg = policySegments[i];
|
|
293
|
+
const iSeg = inputSegments[i];
|
|
294
|
+
if (pSeg === undefined) {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
if (pSeg === '*') {
|
|
298
|
+
continue; // '*'
|
|
299
|
+
}
|
|
300
|
+
if (iSeg === undefined) {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
if (pSeg !== iSeg) {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
142
309
|
}
|
|
143
310
|
|
|
144
311
|
class AbilityTypeGenerator {
|
|
145
312
|
policies;
|
|
313
|
+
policyEntries;
|
|
146
314
|
constructor(policies) {
|
|
147
315
|
this.policies = policies;
|
|
316
|
+
this.policyEntries = policies.map(policy => ({
|
|
317
|
+
policy,
|
|
318
|
+
normalizedPermission: AbilityResolver.normalizePermission(policy.permission),
|
|
319
|
+
segments: AbilityResolver.normalizePermission(policy.permission).split('.'),
|
|
320
|
+
}));
|
|
148
321
|
}
|
|
149
322
|
/**
|
|
150
323
|
* Generates TypeScript type definitions based on the provided policies.
|
|
@@ -152,43 +325,103 @@ class AbilityTypeGenerator {
|
|
|
152
325
|
*/
|
|
153
326
|
generateTypeDefs() {
|
|
154
327
|
// Structure to store types: { [action]: { [subjectPath]: type } }
|
|
155
|
-
const
|
|
328
|
+
const resorceStructure = {};
|
|
329
|
+
const environmentStructure = {};
|
|
330
|
+
// tags
|
|
331
|
+
const allTags = new Set();
|
|
156
332
|
// Iterate through all policies
|
|
157
333
|
this.policies.forEach(policy => {
|
|
334
|
+
policy.tags.forEach(tag => allTags.add(tag));
|
|
158
335
|
const action = policy.permission;
|
|
159
336
|
// Initialize object for action if it doesn't exist
|
|
160
|
-
if (!
|
|
161
|
-
|
|
337
|
+
if (!resorceStructure[action]) {
|
|
338
|
+
resorceStructure[action] = {};
|
|
162
339
|
}
|
|
163
340
|
// Iterate through all ruleSets in the policy
|
|
164
341
|
policy.ruleSet.forEach(ruleSet => {
|
|
165
342
|
// Iterate through all rules in the ruleSet
|
|
166
343
|
ruleSet.rules.forEach(rule => {
|
|
167
344
|
const subjectPath = rule.subject;
|
|
168
|
-
const existingType = typeStructure[action][subjectPath];
|
|
169
345
|
const ruleType = this.determineTypeFromRule(rule);
|
|
170
346
|
if (!ruleType) {
|
|
171
347
|
return;
|
|
172
348
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
349
|
+
// -----------------------------
|
|
350
|
+
// ENVIRONMENT HANDLING (subject)
|
|
351
|
+
// -----------------------------
|
|
352
|
+
if (subjectPath.startsWith('env.')) {
|
|
353
|
+
const envPath = subjectPath.replace(/^env\./, '');
|
|
354
|
+
if (!environmentStructure[action]) {
|
|
355
|
+
environmentStructure[action] = {};
|
|
356
|
+
}
|
|
357
|
+
environmentStructure[action][envPath] = ruleType;
|
|
176
358
|
}
|
|
177
359
|
else {
|
|
178
|
-
|
|
360
|
+
const existingType = resorceStructure[action][subjectPath];
|
|
361
|
+
if (existingType && existingType !== ruleType) {
|
|
362
|
+
resorceStructure[action][subjectPath] = `${existingType} | ${ruleType}`;
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
resorceStructure[action][subjectPath] = ruleType;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// -----------------------------
|
|
369
|
+
// RESOURCE PATH HANDLING (right side)
|
|
370
|
+
// -----------------------------
|
|
371
|
+
if (typeof rule.resource === 'string' && this.isPath(rule.resource)) {
|
|
372
|
+
const resourcePath = rule.resource;
|
|
373
|
+
// env.* справа
|
|
374
|
+
if (resourcePath.startsWith('env.')) {
|
|
375
|
+
const envPath = resourcePath.replace(/^env\./, '');
|
|
376
|
+
if (!environmentStructure[action]) {
|
|
377
|
+
environmentStructure[action] = {};
|
|
378
|
+
}
|
|
379
|
+
const existingEnvType = environmentStructure[action][envPath];
|
|
380
|
+
const targetType = ruleType; // или 'unknown', если хочешь жёстко
|
|
381
|
+
if (existingEnvType && existingEnvType !== targetType) {
|
|
382
|
+
environmentStructure[action][envPath] = `${existingEnvType} | ${targetType}`;
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
environmentStructure[action][envPath] = targetType;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
// обычный ресурс справа
|
|
390
|
+
if (!resorceStructure[action]) {
|
|
391
|
+
resorceStructure[action] = {};
|
|
392
|
+
}
|
|
393
|
+
const existingResType = resorceStructure[action][resourcePath];
|
|
394
|
+
const targetType = ruleType; // или 'unknown'
|
|
395
|
+
if (existingResType && existingResType !== targetType) {
|
|
396
|
+
resorceStructure[action][resourcePath] = `${existingResType} | ${targetType}`;
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
resorceStructure[action][resourcePath] = targetType;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
179
402
|
}
|
|
180
403
|
});
|
|
181
404
|
});
|
|
182
405
|
});
|
|
183
406
|
const filteredStructure = {};
|
|
184
|
-
Object.entries(
|
|
407
|
+
Object.entries(resorceStructure).forEach(([action, fields]) => {
|
|
185
408
|
if (!action.endsWith('.*')) {
|
|
186
409
|
filteredStructure[action] = fields;
|
|
187
410
|
}
|
|
188
411
|
});
|
|
189
412
|
// Transform flat structure into nested structure for easier use
|
|
190
413
|
const nestedStructure = this.buildNestedStructure(filteredStructure);
|
|
191
|
-
|
|
414
|
+
const nestedEnvironment = this.buildNestedStructure(environmentStructure);
|
|
415
|
+
return this.formatTypeDefinitions(nestedStructure, nestedEnvironment, allTags);
|
|
416
|
+
}
|
|
417
|
+
isPath(value) {
|
|
418
|
+
if (typeof value !== 'string') {
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
if (value.startsWith('"') || value.startsWith("'")) {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
return value.includes('.');
|
|
192
425
|
}
|
|
193
426
|
/**
|
|
194
427
|
* Determines TypeScript type based on the rule
|
|
@@ -196,25 +429,32 @@ class AbilityTypeGenerator {
|
|
|
196
429
|
* @returns TypeScript type as string
|
|
197
430
|
*/
|
|
198
431
|
determineTypeFromRule(rule) {
|
|
199
|
-
if (rule.condition.
|
|
200
|
-
rule.condition.isEqual(AbilityCondition.always)) {
|
|
432
|
+
if (rule.condition === AbilityCondition.never || rule.condition === AbilityCondition.always) {
|
|
201
433
|
return null;
|
|
202
434
|
}
|
|
435
|
+
if (rule.condition === AbilityCondition.contains ||
|
|
436
|
+
rule.condition === AbilityCondition.not_contains) {
|
|
437
|
+
return this.getArrayType(rule.resource);
|
|
438
|
+
}
|
|
439
|
+
if (rule.condition === AbilityCondition.length_equals ||
|
|
440
|
+
rule.condition === AbilityCondition.length_greater_than ||
|
|
441
|
+
rule.condition === AbilityCondition.length_less_than) {
|
|
442
|
+
return 'string | readonly unknown[]';
|
|
443
|
+
}
|
|
203
444
|
// Numeric comparisons - always number
|
|
204
|
-
if (rule.condition
|
|
205
|
-
rule.condition
|
|
206
|
-
rule.condition
|
|
207
|
-
rule.condition
|
|
445
|
+
if (rule.condition === AbilityCondition.greater_than ||
|
|
446
|
+
rule.condition === AbilityCondition.greater_or_equal ||
|
|
447
|
+
rule.condition === AbilityCondition.less_than ||
|
|
448
|
+
rule.condition === AbilityCondition.less_or_equal) {
|
|
208
449
|
return 'number';
|
|
209
450
|
}
|
|
210
451
|
// Array operations
|
|
211
|
-
if (rule.condition.
|
|
212
|
-
|
|
213
|
-
return this.getArrayType(rule.resource);
|
|
452
|
+
if (rule.condition === AbilityCondition.in || rule.condition === AbilityCondition.not_in) {
|
|
453
|
+
return this.getInArrayType(rule.resource);
|
|
214
454
|
}
|
|
215
455
|
// Equality/Inequality operations
|
|
216
|
-
if (rule.condition
|
|
217
|
-
rule.condition
|
|
456
|
+
if (rule.condition === AbilityCondition.equals ||
|
|
457
|
+
rule.condition === AbilityCondition.not_equals) {
|
|
218
458
|
return this.getPrimitiveType(rule.resource);
|
|
219
459
|
}
|
|
220
460
|
return 'any';
|
|
@@ -225,20 +465,23 @@ class AbilityTypeGenerator {
|
|
|
225
465
|
* @returns TypeScript array type as string
|
|
226
466
|
*/
|
|
227
467
|
getArrayType(resource) {
|
|
468
|
+
const elementType = this.getInArrayType(resource);
|
|
469
|
+
return `readonly ${elementType}[]`;
|
|
470
|
+
}
|
|
471
|
+
getInArrayType(resource) {
|
|
228
472
|
if (Array.isArray(resource)) {
|
|
229
473
|
if (resource.length === 0) {
|
|
230
|
-
return '
|
|
474
|
+
return 'unknown';
|
|
231
475
|
}
|
|
232
476
|
// Determine types of array elements
|
|
233
477
|
const elementTypes = new Set(resource.map(item => this.getPrimitiveType(item)));
|
|
234
|
-
|
|
478
|
+
return elementTypes.size === 1
|
|
235
479
|
? Array.from(elementTypes)[0]
|
|
236
480
|
: `(${Array.from(elementTypes).join(' | ')})`;
|
|
237
|
-
return `readonly ${elementType}[]`;
|
|
238
481
|
}
|
|
239
482
|
// If resource is not an array but condition is in/not_in,
|
|
240
483
|
// it expects an array of such elements
|
|
241
|
-
return
|
|
484
|
+
return this.getPrimitiveType(resource);
|
|
242
485
|
}
|
|
243
486
|
/**
|
|
244
487
|
* Gets primitive TypeScript type for a value
|
|
@@ -247,11 +490,15 @@ class AbilityTypeGenerator {
|
|
|
247
490
|
*/
|
|
248
491
|
getPrimitiveType(value) {
|
|
249
492
|
if (value === null) {
|
|
250
|
-
return 'null';
|
|
493
|
+
return 'null | unknown';
|
|
251
494
|
}
|
|
252
495
|
if (value === undefined) {
|
|
253
496
|
return 'undefined';
|
|
254
497
|
}
|
|
498
|
+
if (typeof value === 'string' && this.isPath(value)) {
|
|
499
|
+
// This is not a string literal, but a path to another field.
|
|
500
|
+
return 'unknown';
|
|
501
|
+
}
|
|
255
502
|
switch (typeof value) {
|
|
256
503
|
case 'string':
|
|
257
504
|
return 'string';
|
|
@@ -304,28 +551,100 @@ class AbilityTypeGenerator {
|
|
|
304
551
|
/**
|
|
305
552
|
* Formats type structure into a string
|
|
306
553
|
* @param structure - Nested type structure
|
|
554
|
+
* @param environment
|
|
555
|
+
* @param allTags
|
|
307
556
|
* @returns Formatted TypeScript type definition string
|
|
308
557
|
*/
|
|
309
|
-
formatTypeDefinitions(structure) {
|
|
558
|
+
formatTypeDefinitions(structure, environment, allTags) {
|
|
310
559
|
let output = '// Automatically generated by via-profit/ability\n';
|
|
311
560
|
output += '// Do not edit manually\n';
|
|
312
561
|
output += 'export type Resources = {\n';
|
|
313
562
|
const sortedActions = Object.keys(structure).sort();
|
|
314
|
-
sortedActions.forEach(
|
|
315
|
-
const actionObj = structure[
|
|
563
|
+
sortedActions.forEach(permission => {
|
|
564
|
+
const actionObj = structure[permission];
|
|
316
565
|
const isEmpty = Object.keys(actionObj).length === 0;
|
|
566
|
+
const inputNormalized = AbilityResolver.normalizePermission(permission);
|
|
567
|
+
const inputSegments = inputNormalized.split('.');
|
|
568
|
+
const filteredPolicies = this.policyEntries
|
|
569
|
+
.filter(entry => AbilityResolver.matchPermissions(entry.segments, inputSegments))
|
|
570
|
+
.map(entry => entry.policy);
|
|
571
|
+
// Effects
|
|
572
|
+
const effects = [...new Set(filteredPolicies.map(p => p.effect))].sort();
|
|
573
|
+
// Policies list
|
|
574
|
+
const items = filteredPolicies
|
|
575
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
576
|
+
.map(p => {
|
|
577
|
+
const effect = p.effect.padEnd(6, ' '); // permit / deny / audit
|
|
578
|
+
const displayName = p.name === p.id ? 'Unnamed policy' : p.name;
|
|
579
|
+
return ` * - ${effect} ${p.id} "${displayName}"`;
|
|
580
|
+
})
|
|
581
|
+
.join('\n');
|
|
582
|
+
//
|
|
583
|
+
output += `
|
|
584
|
+
/**
|
|
585
|
+
* Permission: ${permission}
|
|
586
|
+
* Effects: ${effects.join(', ')}
|
|
587
|
+
* Policies:
|
|
588
|
+
${items}
|
|
589
|
+
*/
|
|
590
|
+
`;
|
|
317
591
|
if (isEmpty) {
|
|
318
|
-
//
|
|
319
|
-
output += ` ['${
|
|
592
|
+
// empty object → undefined
|
|
593
|
+
output += ` ['${permission}']: undefined;\n`;
|
|
320
594
|
}
|
|
321
595
|
else {
|
|
322
|
-
//
|
|
323
|
-
output += ` ['${
|
|
596
|
+
// not empty object
|
|
597
|
+
output += ` ['${permission}']: {\n`;
|
|
324
598
|
output += this.formatNestedObject(actionObj, 4);
|
|
325
|
-
output += ' };\n';
|
|
599
|
+
output += ' } | null | undefined;\n';
|
|
326
600
|
}
|
|
327
601
|
});
|
|
328
602
|
output += '}\n';
|
|
603
|
+
// tags
|
|
604
|
+
const tagsUnion = allTags.size > 0
|
|
605
|
+
? Array.from(allTags)
|
|
606
|
+
.sort()
|
|
607
|
+
.map(tag => `'${tag}'`)
|
|
608
|
+
.join(' | ')
|
|
609
|
+
: 'never';
|
|
610
|
+
output += `\n\nexport type PolicyTags = ${tagsUnion};\n`;
|
|
611
|
+
// environments
|
|
612
|
+
output += '\n\nexport type Environment = {\n';
|
|
613
|
+
Object.entries(environment).forEach(([permission, envObj]) => {
|
|
614
|
+
const isEmpty = Object.keys(envObj).length === 0;
|
|
615
|
+
const inputNormalized = AbilityResolver.normalizePermission(permission);
|
|
616
|
+
const inputSegments = inputNormalized.split('.');
|
|
617
|
+
const filteredPolicies = this.policyEntries
|
|
618
|
+
.filter(entry => AbilityResolver.matchPermissions(entry.segments, inputSegments))
|
|
619
|
+
.map(entry => entry.policy);
|
|
620
|
+
const effects = [...new Set(filteredPolicies.map(p => p.effect))].sort();
|
|
621
|
+
const items = filteredPolicies
|
|
622
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
623
|
+
.map(p => {
|
|
624
|
+
const effect = p.effect.padEnd(6, ' ');
|
|
625
|
+
const displayName = p.name === p.id ? 'Unnamed policy' : p.name;
|
|
626
|
+
return ` * - ${effect} ${p.id} "${displayName}"`;
|
|
627
|
+
})
|
|
628
|
+
.join('\n');
|
|
629
|
+
output += `
|
|
630
|
+
/**
|
|
631
|
+
* Permission: ${permission}
|
|
632
|
+
* Effects: ${effects.join(', ')}
|
|
633
|
+
* Policies:
|
|
634
|
+
${items}
|
|
635
|
+
*/
|
|
636
|
+
`;
|
|
637
|
+
if (isEmpty) {
|
|
638
|
+
output += ` ['${permission}']: undefined;\n`;
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
output += ` ['${permission}']: {\n`;
|
|
642
|
+
output += this.formatNestedObject(envObj, 4);
|
|
643
|
+
output += ' } | null | undefined;\n';
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
output += '}\n';
|
|
647
|
+
// complex
|
|
329
648
|
return output;
|
|
330
649
|
}
|
|
331
650
|
/**
|
|
@@ -345,69 +664,126 @@ class AbilityTypeGenerator {
|
|
|
345
664
|
// Nested object
|
|
346
665
|
output += `${spaces}readonly ${key}: {\n`;
|
|
347
666
|
output += this.formatNestedObject(value, indent + 2);
|
|
348
|
-
output += `${spaces}};\n`;
|
|
667
|
+
output += `${spaces}} | null | undefined;\n`;
|
|
349
668
|
}
|
|
350
669
|
else {
|
|
351
670
|
// Primitive type
|
|
352
|
-
|
|
671
|
+
const va = [String(value)];
|
|
672
|
+
let v = String(value);
|
|
673
|
+
if (!v.match(/unknown/)) {
|
|
674
|
+
if (!v.match(/null/)) {
|
|
675
|
+
va.push('null');
|
|
676
|
+
}
|
|
677
|
+
if (!v.match(/undefined/)) {
|
|
678
|
+
va.push('undefined');
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
output += `${spaces}readonly ${key}: ${va.join(' | ')} \n`;
|
|
353
682
|
}
|
|
354
683
|
});
|
|
355
684
|
return output;
|
|
356
685
|
}
|
|
357
686
|
}
|
|
358
687
|
|
|
359
|
-
class
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
688
|
+
class AbilityHash {
|
|
689
|
+
static sha1(message) {
|
|
690
|
+
const msgBytes = AbilityHash.stringToBytes(message);
|
|
691
|
+
const msgBitLength = msgBytes.length * 8;
|
|
692
|
+
const withOne = new Uint8Array(msgBytes.length + 1);
|
|
693
|
+
withOne.set(msgBytes, 0);
|
|
694
|
+
withOne[msgBytes.length] = 0x80;
|
|
695
|
+
let zeroBytes = (56 - (withOne.length % 64) + 64) % 64;
|
|
696
|
+
const padded = new Uint8Array(withOne.length + zeroBytes + 8);
|
|
697
|
+
padded.set(withOne, 0);
|
|
698
|
+
const bitLenHigh = Math.floor(msgBitLength / 0x100000000);
|
|
699
|
+
const bitLenLow = msgBitLength >>> 0;
|
|
700
|
+
padded[padded.length - 8] = (bitLenHigh >>> 24) & 0xff;
|
|
701
|
+
padded[padded.length - 7] = (bitLenHigh >>> 16) & 0xff;
|
|
702
|
+
padded[padded.length - 6] = (bitLenHigh >>> 8) & 0xff;
|
|
703
|
+
padded[padded.length - 5] = bitLenHigh & 0xff;
|
|
704
|
+
padded[padded.length - 4] = (bitLenLow >>> 24) & 0xff;
|
|
705
|
+
padded[padded.length - 3] = (bitLenLow >>> 16) & 0xff;
|
|
706
|
+
padded[padded.length - 2] = (bitLenLow >>> 8) & 0xff;
|
|
707
|
+
padded[padded.length - 1] = bitLenLow & 0xff;
|
|
708
|
+
let h0 = 0x67452301;
|
|
709
|
+
let h1 = 0xefcdab89;
|
|
710
|
+
let h2 = 0x98badcfe;
|
|
711
|
+
let h3 = 0x10325476;
|
|
712
|
+
let h4 = 0xc3d2e1f0;
|
|
713
|
+
const w = new Array(80);
|
|
714
|
+
for (let i = 0; i < padded.length; i += 64) {
|
|
715
|
+
for (let j = 0; j < 16; j++) {
|
|
716
|
+
const idx = i + j * 4;
|
|
717
|
+
w[j] =
|
|
718
|
+
(padded[idx] << 24) | (padded[idx + 1] << 16) | (padded[idx + 2] << 8) | padded[idx + 3];
|
|
719
|
+
}
|
|
720
|
+
for (let j = 16; j < 80; j++) {
|
|
721
|
+
w[j] = AbilityHash.leftRotate(w[j - 3] ^ w[j - 8] ^ w[j - 14] ^ w[j - 16], 1);
|
|
722
|
+
}
|
|
723
|
+
let a = h0;
|
|
724
|
+
let b = h1;
|
|
725
|
+
let c = h2;
|
|
726
|
+
let d = h3;
|
|
727
|
+
let e = h4;
|
|
728
|
+
for (let j = 0; j < 80; j++) {
|
|
729
|
+
let f;
|
|
730
|
+
let k;
|
|
731
|
+
if (j < 20) {
|
|
732
|
+
f = (b & c) | (~b & d);
|
|
733
|
+
k = 0x5a827999;
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
if (j < 40) {
|
|
737
|
+
f = b ^ c ^ d;
|
|
738
|
+
k = 0x6ed9eba1;
|
|
739
|
+
}
|
|
740
|
+
else {
|
|
741
|
+
if (j < 60) {
|
|
742
|
+
f = (b & c) | (b & d) | (c & d);
|
|
743
|
+
k = 0x8f1bbcdc;
|
|
744
|
+
}
|
|
745
|
+
else {
|
|
746
|
+
f = b ^ c ^ d;
|
|
747
|
+
k = 0xca62c1d6;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
const temp = (AbilityHash.leftRotate(a, 5) + f + e + k + (w[j] | 0)) | 0;
|
|
752
|
+
e = d;
|
|
753
|
+
d = c;
|
|
754
|
+
c = AbilityHash.leftRotate(b, 30);
|
|
755
|
+
b = a;
|
|
756
|
+
a = temp;
|
|
757
|
+
}
|
|
758
|
+
h0 = (h0 + a) | 0;
|
|
759
|
+
h1 = (h1 + b) | 0;
|
|
760
|
+
h2 = (h2 + c) | 0;
|
|
761
|
+
h3 = (h3 + d) | 0;
|
|
762
|
+
h4 = (h4 + e) | 0;
|
|
763
|
+
}
|
|
764
|
+
return [
|
|
765
|
+
AbilityHash.toHex32(h0),
|
|
766
|
+
AbilityHash.toHex32(h1),
|
|
767
|
+
AbilityHash.toHex32(h2),
|
|
768
|
+
AbilityHash.toHex32(h3),
|
|
769
|
+
AbilityHash.toHex32(h4),
|
|
770
|
+
].join('');
|
|
771
|
+
}
|
|
772
|
+
static leftRotate(value, bits) {
|
|
773
|
+
return ((value << bits) | (value >>> (32 - bits))) >>> 0;
|
|
774
|
+
}
|
|
775
|
+
static toHex32(num) {
|
|
776
|
+
return (num >>> 0).toString(16).padStart(8, '0');
|
|
777
|
+
}
|
|
778
|
+
static stringToBytes(str) {
|
|
779
|
+
if (typeof TextEncoder !== 'undefined') {
|
|
780
|
+
const encoder = new TextEncoder();
|
|
781
|
+
return encoder.encode(str);
|
|
782
|
+
}
|
|
783
|
+
else {
|
|
784
|
+
const buf = Buffer.from(str, 'utf8');
|
|
785
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
376
786
|
}
|
|
377
|
-
out += `${pad}${mark} ${this.type} «${this.name}» is ${this.match.code}`;
|
|
378
|
-
this.children.forEach(child => {
|
|
379
|
-
out += '\n' + child.toString(indent + 1);
|
|
380
|
-
});
|
|
381
|
-
return out;
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
class AbilityExplainRule extends AbilityExplain {
|
|
385
|
-
constructor(rule) {
|
|
386
|
-
super({
|
|
387
|
-
type: 'rule',
|
|
388
|
-
match: rule.state,
|
|
389
|
-
name: rule.name,
|
|
390
|
-
});
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
class AbilityExplainRuleSet extends AbilityExplain {
|
|
394
|
-
constructor(ruleSet) {
|
|
395
|
-
const children = ruleSet.rules.map(rule => new AbilityExplainRule(rule));
|
|
396
|
-
super({
|
|
397
|
-
type: 'ruleSet',
|
|
398
|
-
match: ruleSet.state,
|
|
399
|
-
name: ruleSet.name,
|
|
400
|
-
}, children);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
class AbilityExplainPolicy extends AbilityExplain {
|
|
404
|
-
constructor(policy) {
|
|
405
|
-
const children = policy.ruleSet.map(ruleSet => new AbilityExplainRuleSet(ruleSet));
|
|
406
|
-
super({
|
|
407
|
-
type: 'policy',
|
|
408
|
-
name: policy.name,
|
|
409
|
-
match: policy.matchState,
|
|
410
|
-
}, children);
|
|
411
787
|
}
|
|
412
788
|
}
|
|
413
789
|
|
|
@@ -428,26 +804,34 @@ class AbilityPolicy {
|
|
|
428
804
|
* one of the rules returns as «permit»
|
|
429
805
|
*/
|
|
430
806
|
compareMethod = AbilityCompare.and;
|
|
431
|
-
/**
|
|
432
|
-
* Policy name
|
|
433
|
-
*/
|
|
434
|
-
name;
|
|
435
807
|
/**
|
|
436
808
|
* Policy ID
|
|
437
809
|
*/
|
|
438
810
|
id;
|
|
811
|
+
/**
|
|
812
|
+
* Policy name
|
|
813
|
+
*/
|
|
814
|
+
name;
|
|
815
|
+
description;
|
|
439
816
|
/**
|
|
440
817
|
* Running the `enforce` or `resolve` method
|
|
441
818
|
* will select only those from all passed policies that fall under the specified permission key.
|
|
442
819
|
*/
|
|
443
820
|
permission;
|
|
821
|
+
priority = -1;
|
|
822
|
+
disabled;
|
|
823
|
+
tags;
|
|
444
824
|
constructor(params) {
|
|
445
|
-
const { name, id, permission, effect, compareMethod = AbilityCompare.and } = params;
|
|
446
|
-
this.name = name;
|
|
447
|
-
this.id = id;
|
|
825
|
+
const { name, description, id, permission, effect, compareMethod = AbilityCompare.and, priority, disabled, tags, } = params;
|
|
448
826
|
this.permission = permission;
|
|
827
|
+
this.description = description;
|
|
449
828
|
this.effect = effect;
|
|
450
829
|
this.compareMethod = compareMethod;
|
|
830
|
+
this.priority = typeof priority === 'number' ? priority : -1;
|
|
831
|
+
this.disabled = typeof disabled === 'boolean' ? disabled : false;
|
|
832
|
+
this.tags = (tags || []);
|
|
833
|
+
this.id = id || `p_${this.hash().slice(0, 10)}`;
|
|
834
|
+
this.name = name || this.id;
|
|
451
835
|
}
|
|
452
836
|
/**
|
|
453
837
|
* Add rule set to the policy
|
|
@@ -467,6 +851,18 @@ class AbilityPolicy {
|
|
|
467
851
|
}
|
|
468
852
|
return this;
|
|
469
853
|
}
|
|
854
|
+
/**
|
|
855
|
+
* Extract all rules of all ruleSets of this policy
|
|
856
|
+
*/
|
|
857
|
+
extractRules() {
|
|
858
|
+
const rules = [];
|
|
859
|
+
for (const ruleSet of this.ruleSet) {
|
|
860
|
+
for (const rule of ruleSet.rules) {
|
|
861
|
+
rules.push(rule);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return rules;
|
|
865
|
+
}
|
|
470
866
|
/**
|
|
471
867
|
* Check if the policy is matched
|
|
472
868
|
* @param resource - The resource to check
|
|
@@ -474,31 +870,57 @@ class AbilityPolicy {
|
|
|
474
870
|
*/
|
|
475
871
|
check(resource, environment) {
|
|
476
872
|
this.matchState = AbilityMatch.mismatch;
|
|
873
|
+
if (this.disabled) {
|
|
874
|
+
this.matchState = AbilityMatch.disabled;
|
|
875
|
+
return this.matchState;
|
|
876
|
+
}
|
|
477
877
|
if (!this.ruleSet.length) {
|
|
478
878
|
return this.matchState;
|
|
479
879
|
}
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
if (
|
|
485
|
-
|
|
880
|
+
const normalGroups = this.ruleSet.filter(g => !g.isExcept);
|
|
881
|
+
const exceptGroups = this.ruleSet.filter(g => g.isExcept);
|
|
882
|
+
const normalStates = [];
|
|
883
|
+
for (const group of normalGroups) {
|
|
884
|
+
if (group.disabled) {
|
|
885
|
+
continue;
|
|
486
886
|
}
|
|
487
|
-
|
|
488
|
-
|
|
887
|
+
const state = group.check(resource, environment);
|
|
888
|
+
normalStates.push(state);
|
|
889
|
+
if (AbilityCompare.and === this.compareMethod && AbilityMatch.mismatch === state) {
|
|
890
|
+
this.matchState = AbilityMatch.mismatch;
|
|
489
891
|
return this.matchState;
|
|
490
892
|
}
|
|
491
|
-
|
|
492
|
-
if (AbilityCompare.and.isEqual(this.compareMethod)) {
|
|
493
|
-
if (rulesetCheckStates.every(s => AbilityMatch.match.isEqual(s))) {
|
|
893
|
+
if (AbilityCompare.or === this.compareMethod && AbilityMatch.match === state) {
|
|
494
894
|
this.matchState = AbilityMatch.match;
|
|
895
|
+
// break to check except-rule sets
|
|
896
|
+
break;
|
|
495
897
|
}
|
|
496
898
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
899
|
+
// 3. Simple rule sets
|
|
900
|
+
let normalMatch = false;
|
|
901
|
+
if (AbilityCompare.and === this.compareMethod) {
|
|
902
|
+
normalMatch = normalStates.every(s => AbilityMatch.match === s);
|
|
903
|
+
}
|
|
904
|
+
else {
|
|
905
|
+
normalMatch = normalStates.some(s => AbilityMatch.match === s);
|
|
906
|
+
}
|
|
907
|
+
if (!normalMatch) {
|
|
908
|
+
this.matchState = AbilityMatch.mismatch;
|
|
909
|
+
return this.matchState;
|
|
910
|
+
}
|
|
911
|
+
// 4. except-rule sets
|
|
912
|
+
for (const group of exceptGroups) {
|
|
913
|
+
if (group.disabled) {
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
const state = group.check(resource, environment);
|
|
917
|
+
if (AbilityMatch.match === state) {
|
|
918
|
+
this.matchState = AbilityMatch.exceptMismatch;
|
|
919
|
+
return this.matchState;
|
|
500
920
|
}
|
|
501
921
|
}
|
|
922
|
+
// 5. match
|
|
923
|
+
this.matchState = AbilityMatch.match;
|
|
502
924
|
return this.matchState;
|
|
503
925
|
}
|
|
504
926
|
explain() {
|
|
@@ -511,6 +933,8 @@ class AbilityPolicy {
|
|
|
511
933
|
const policy = new AbilityPolicy({
|
|
512
934
|
id: props.id ?? this.id,
|
|
513
935
|
name: props.name ?? this.name,
|
|
936
|
+
description: props.description ?? this.description,
|
|
937
|
+
priority: typeof props.priority !== 'undefined' ? props.priority : this.priority,
|
|
514
938
|
permission: props.permission ?? this.permission,
|
|
515
939
|
effect: props.effect ?? this.effect,
|
|
516
940
|
compareMethod: props.compareMethod ?? this.compareMethod,
|
|
@@ -521,124 +945,33 @@ class AbilityPolicy {
|
|
|
521
945
|
}
|
|
522
946
|
return policy;
|
|
523
947
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
static neutral = new AbilityResultState('neutral');
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
class AbilityResult {
|
|
538
|
-
/**
|
|
539
|
-
* Already checked policies (after call the policy.check())
|
|
540
|
-
*/
|
|
541
|
-
policies;
|
|
542
|
-
constructor(policies) {
|
|
543
|
-
this.policies = policies;
|
|
544
|
-
}
|
|
545
|
-
/**
|
|
546
|
-
* Returns a list of explanations for each policy involved in the ability evaluation.
|
|
547
|
-
* Each item describes how a specific policy contributed to the final permission result.
|
|
548
|
-
*
|
|
549
|
-
* Useful for debugging, logging, or building UI tools that visualize permission logic.
|
|
550
|
-
*/
|
|
551
|
-
explain() {
|
|
552
|
-
return this.policies.map(policy => {
|
|
553
|
-
return new AbilityExplainPolicy(policy);
|
|
554
|
-
});
|
|
555
|
-
}
|
|
556
|
-
getLastMatchedPolicy() {
|
|
557
|
-
for (let i = this.policies.length - 1; i >= 0; i--) {
|
|
558
|
-
if (this.policies[i].matchState.isEqual(AbilityMatch.match)) {
|
|
559
|
-
return this.policies[i];
|
|
560
|
-
}
|
|
948
|
+
hash() {
|
|
949
|
+
const parts = [
|
|
950
|
+
`permission:${this.permission}`,
|
|
951
|
+
`effect:${this.effect}`,
|
|
952
|
+
`compareMethod:${this.compareMethod}`,
|
|
953
|
+
`priority:${this.priority}`,
|
|
954
|
+
`disabled:${this.disabled}`,
|
|
955
|
+
];
|
|
956
|
+
if (this.tags && this.tags.length > 0) {
|
|
957
|
+
parts.push(`tags:${[...this.tags].sort().join(',')}`);
|
|
561
958
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
let state = AbilityResultState.neutral;
|
|
566
|
-
for (const p of this.policies) {
|
|
567
|
-
if (p.matchState.isEqual(AbilityMatch.match)) {
|
|
568
|
-
if (p.effect.isEqual(AbilityPolicyEffect.permit)) {
|
|
569
|
-
state = AbilityResultState.allow;
|
|
570
|
-
}
|
|
571
|
-
else if (p.effect.isEqual(AbilityPolicyEffect.deny)) {
|
|
572
|
-
state = AbilityResultState.deny;
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
else if (p.matchState.isEqual(AbilityMatch.mismatch)) {
|
|
576
|
-
state = AbilityResultState.neutral;
|
|
577
|
-
}
|
|
959
|
+
if (this.ruleSet && this.ruleSet.length > 0) {
|
|
960
|
+
const ruleHashes = this.ruleSet.map(r => r.hash());
|
|
961
|
+
parts.push(`rules:${ruleHashes.sort().join('|')}`);
|
|
578
962
|
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
isAllowed() {
|
|
582
|
-
return this.getFinalState().isEqual(AbilityResultState.allow);
|
|
583
|
-
}
|
|
584
|
-
isDenied() {
|
|
585
|
-
return this.getFinalState().isNotEqual(AbilityResultState.allow);
|
|
963
|
+
const str = parts.join(';');
|
|
964
|
+
return AbilityHash.sha1(str);
|
|
586
965
|
}
|
|
587
966
|
}
|
|
588
967
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
constructor(
|
|
592
|
-
/**
|
|
593
|
-
* `Important!` The incorrect Resources type was intentionally passed to AbilityPolicy so that TypeScript could suggest the name of the permission and the structure of its resource in the parse method.
|
|
594
|
-
*/
|
|
595
|
-
policyOrListOfPolicies) {
|
|
596
|
-
this.policies = Array.isArray(policyOrListOfPolicies)
|
|
597
|
-
? policyOrListOfPolicies
|
|
598
|
-
: [policyOrListOfPolicies];
|
|
599
|
-
}
|
|
600
|
-
/**
|
|
601
|
-
* Resolve policy for the resource and permission key
|
|
602
|
-
*
|
|
603
|
-
* @param permission - Permission key
|
|
604
|
-
* @param resource - Resource
|
|
605
|
-
* @param environment
|
|
606
|
-
*/
|
|
607
|
-
resolve(permission, resource, environment) {
|
|
608
|
-
const filteredPolicies = this.policies.filter(policy => AbilityResolver.isInPermissionContain(policy.permission, String(permission).replace(/^permission\./, '')));
|
|
609
|
-
for (const policy of filteredPolicies) {
|
|
610
|
-
const policyMatchState = policy.check(resource, environment);
|
|
611
|
-
if (policyMatchState === AbilityMatch.pending) {
|
|
612
|
-
throw new AbilityError(`The policy "${policy.name}" is still in a pending state. Make sure to call "check" to evaluate the policy before resolving permissions.`);
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
return new AbilityResult(filteredPolicies);
|
|
616
|
-
}
|
|
617
|
-
enforce(permission, resource, environment) {
|
|
618
|
-
const result = this.resolve(permission, resource, environment);
|
|
619
|
-
if (result.isDenied()) {
|
|
620
|
-
const lastPolicy = result.getLastMatchedPolicy();
|
|
621
|
-
if (lastPolicy) {
|
|
622
|
-
throw new AbilityError(`Permission denied by policy "${lastPolicy.name.toString()}"`);
|
|
623
|
-
}
|
|
624
|
-
// No policy matched → implicit deny
|
|
625
|
-
throw new AbilityError(`Permission denied: no matching policy found (implicit deny)`);
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
/**
|
|
629
|
-
* Check if the permission key is contained in another permission key
|
|
630
|
-
* @param permissionA - The first permission to check
|
|
631
|
-
* @param permissionB - The second permission to check
|
|
632
|
-
*/
|
|
633
|
-
static isInPermissionContain(permissionA, permissionB) {
|
|
634
|
-
const A = permissionA.split('.');
|
|
635
|
-
const B = permissionB.split('.');
|
|
636
|
-
const [longer, shorter] = A.length >= B.length ? [A, B] : [B, A];
|
|
637
|
-
return shorter.every((chunk, i) => {
|
|
638
|
-
return chunk === '*' || longer[i] === '*' || chunk === longer[i];
|
|
639
|
-
});
|
|
640
|
-
}
|
|
968
|
+
function brand$1(code) {
|
|
969
|
+
return code;
|
|
641
970
|
}
|
|
971
|
+
const AbilityPolicyEffect = {
|
|
972
|
+
deny: brand$1('deny'),
|
|
973
|
+
permit: brand$1('permit'),
|
|
974
|
+
};
|
|
642
975
|
|
|
643
976
|
/**
|
|
644
977
|
* Represents a rule that defines a condition to be checked against a subject and resource.
|
|
@@ -654,8 +987,10 @@ class AbilityRule {
|
|
|
654
987
|
resource;
|
|
655
988
|
condition;
|
|
656
989
|
name;
|
|
990
|
+
description;
|
|
657
991
|
id;
|
|
658
992
|
state = AbilityMatch.pending;
|
|
993
|
+
disabled;
|
|
659
994
|
/**
|
|
660
995
|
* Creates an instance of AbilityRule.
|
|
661
996
|
* @param {string} params.id - The unique identifier of the rule.
|
|
@@ -663,33 +998,37 @@ class AbilityRule {
|
|
|
663
998
|
* @param {AbilityCondition} params.condition - The condition to evaluate.
|
|
664
999
|
* @param {string} params.subject - The subject of the rule.
|
|
665
1000
|
* @param {string} params.resource - The resource to compare against.
|
|
1001
|
+
* @param {boolean} params.disabled - Disabling flag.
|
|
666
1002
|
* @param params
|
|
667
1003
|
*/
|
|
668
1004
|
constructor(params) {
|
|
669
|
-
const { id, name, subject, resource, condition } = params;
|
|
670
|
-
this.
|
|
671
|
-
this.
|
|
1005
|
+
const { id, name, subject, resource, condition, disabled, description } = params;
|
|
1006
|
+
this.description = description;
|
|
1007
|
+
this.disabled = typeof disabled === 'boolean' ? disabled : false;
|
|
672
1008
|
this.subject = subject;
|
|
673
1009
|
this.resource = resource;
|
|
674
1010
|
this.condition = condition;
|
|
1011
|
+
this.state = this.disabled ? AbilityMatch.disabled : this.state;
|
|
1012
|
+
this.id = id || `r_${this.hash().slice(0, 10)}`;
|
|
1013
|
+
this.name = name || this.id;
|
|
675
1014
|
}
|
|
676
|
-
isPrimitive(v) {
|
|
1015
|
+
static isPrimitive(v) {
|
|
677
1016
|
return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v === null;
|
|
678
1017
|
}
|
|
679
|
-
isNumber(v) {
|
|
1018
|
+
static isNumber(v) {
|
|
680
1019
|
return typeof v === 'number';
|
|
681
1020
|
}
|
|
682
|
-
isString(v) {
|
|
1021
|
+
static isString(v) {
|
|
683
1022
|
return typeof v === 'string';
|
|
684
1023
|
}
|
|
685
|
-
valueLen = (v) => this.isString(v) || Array.isArray(v) ? v.length : null;
|
|
686
|
-
operatorHandlers = {
|
|
687
|
-
[AbilityCondition.always
|
|
688
|
-
[AbilityCondition.never
|
|
689
|
-
[AbilityCondition.equals
|
|
690
|
-
[AbilityCondition.not_equals
|
|
691
|
-
[AbilityCondition.contains
|
|
692
|
-
if (Array.isArray(a) &&
|
|
1024
|
+
static valueLen = (v) => this.isString(v) || Array.isArray(v) ? v.length : null;
|
|
1025
|
+
static operatorHandlers = {
|
|
1026
|
+
[toLiteral(AbilityCondition.always)]: () => true,
|
|
1027
|
+
[toLiteral(AbilityCondition.never)]: () => false,
|
|
1028
|
+
[toLiteral(AbilityCondition.equals)]: (a, b) => a === b,
|
|
1029
|
+
[toLiteral(AbilityCondition.not_equals)]: (a, b) => a !== b,
|
|
1030
|
+
[toLiteral(AbilityCondition.contains)]: (a, b) => {
|
|
1031
|
+
if (Array.isArray(a) && AbilityRule.isPrimitive(b)) {
|
|
693
1032
|
return a.includes(b);
|
|
694
1033
|
}
|
|
695
1034
|
if (Array.isArray(a) && Array.isArray(b)) {
|
|
@@ -697,8 +1036,8 @@ class AbilityRule {
|
|
|
697
1036
|
}
|
|
698
1037
|
return false;
|
|
699
1038
|
},
|
|
700
|
-
[AbilityCondition.not_contains
|
|
701
|
-
if (Array.isArray(a) &&
|
|
1039
|
+
[toLiteral(AbilityCondition.not_contains)]: (a, b) => {
|
|
1040
|
+
if (Array.isArray(a) && AbilityRule.isPrimitive(b)) {
|
|
702
1041
|
return !a.includes(b);
|
|
703
1042
|
}
|
|
704
1043
|
if (Array.isArray(a) && Array.isArray(b)) {
|
|
@@ -706,8 +1045,8 @@ class AbilityRule {
|
|
|
706
1045
|
}
|
|
707
1046
|
return false;
|
|
708
1047
|
},
|
|
709
|
-
[AbilityCondition.in
|
|
710
|
-
if (
|
|
1048
|
+
[toLiteral(AbilityCondition.in)]: (a, b) => {
|
|
1049
|
+
if (AbilityRule.isPrimitive(a) && Array.isArray(b)) {
|
|
711
1050
|
return b.includes(a);
|
|
712
1051
|
}
|
|
713
1052
|
if (Array.isArray(a) && Array.isArray(b)) {
|
|
@@ -715,8 +1054,8 @@ class AbilityRule {
|
|
|
715
1054
|
}
|
|
716
1055
|
return false;
|
|
717
1056
|
},
|
|
718
|
-
[AbilityCondition.not_in
|
|
719
|
-
if (
|
|
1057
|
+
[toLiteral(AbilityCondition.not_in)]: (a, b) => {
|
|
1058
|
+
if (AbilityRule.isPrimitive(a) && Array.isArray(b)) {
|
|
720
1059
|
return !b.includes(a);
|
|
721
1060
|
}
|
|
722
1061
|
if (Array.isArray(a) && Array.isArray(b)) {
|
|
@@ -724,55 +1063,55 @@ class AbilityRule {
|
|
|
724
1063
|
}
|
|
725
1064
|
return false;
|
|
726
1065
|
},
|
|
727
|
-
[AbilityCondition.greater_than
|
|
728
|
-
return
|
|
1066
|
+
[toLiteral(AbilityCondition.greater_than)]: (a, b) => {
|
|
1067
|
+
return AbilityRule.isNumber(a) && AbilityRule.isNumber(b) ? a > b : false;
|
|
729
1068
|
},
|
|
730
|
-
[AbilityCondition.less_than
|
|
731
|
-
return
|
|
1069
|
+
[toLiteral(AbilityCondition.less_than)]: (a, b) => {
|
|
1070
|
+
return AbilityRule.isNumber(a) && AbilityRule.isNumber(b) ? a < b : false;
|
|
732
1071
|
},
|
|
733
|
-
[AbilityCondition.greater_or_equal
|
|
734
|
-
return
|
|
1072
|
+
[toLiteral(AbilityCondition.greater_or_equal)]: (a, b) => {
|
|
1073
|
+
return AbilityRule.isNumber(a) && AbilityRule.isNumber(b) ? a >= b : false;
|
|
735
1074
|
},
|
|
736
|
-
[AbilityCondition.less_or_equal
|
|
737
|
-
return
|
|
1075
|
+
[toLiteral(AbilityCondition.less_or_equal)]: (a, b) => {
|
|
1076
|
+
return AbilityRule.isNumber(a) && AbilityRule.isNumber(b) ? a <= b : false;
|
|
738
1077
|
},
|
|
739
|
-
[AbilityCondition.length_greater_than
|
|
740
|
-
const alen =
|
|
1078
|
+
[toLiteral(AbilityCondition.length_greater_than)]: (a, b) => {
|
|
1079
|
+
const alen = AbilityRule.valueLen(a);
|
|
741
1080
|
if (alen === null) {
|
|
742
1081
|
return false;
|
|
743
1082
|
}
|
|
744
|
-
if (
|
|
1083
|
+
if (AbilityRule.isNumber(b)) {
|
|
745
1084
|
return alen > b;
|
|
746
1085
|
}
|
|
747
|
-
const bLen =
|
|
1086
|
+
const bLen = AbilityRule.valueLen(b);
|
|
748
1087
|
if (bLen !== null) {
|
|
749
1088
|
return alen > bLen;
|
|
750
1089
|
}
|
|
751
1090
|
return false;
|
|
752
1091
|
},
|
|
753
|
-
[AbilityCondition.length_less_than
|
|
754
|
-
const alen =
|
|
1092
|
+
[toLiteral(AbilityCondition.length_less_than)]: (a, b) => {
|
|
1093
|
+
const alen = AbilityRule.valueLen(a);
|
|
755
1094
|
if (alen === null) {
|
|
756
1095
|
return false;
|
|
757
1096
|
}
|
|
758
|
-
if (
|
|
1097
|
+
if (AbilityRule.isNumber(b)) {
|
|
759
1098
|
return alen < b;
|
|
760
1099
|
}
|
|
761
|
-
const bLen =
|
|
1100
|
+
const bLen = AbilityRule.valueLen(b);
|
|
762
1101
|
if (bLen !== null) {
|
|
763
1102
|
return alen < bLen;
|
|
764
1103
|
}
|
|
765
1104
|
return false;
|
|
766
1105
|
},
|
|
767
|
-
[AbilityCondition.length_equals
|
|
768
|
-
const alen =
|
|
1106
|
+
[toLiteral(AbilityCondition.length_equals)]: (a, b) => {
|
|
1107
|
+
const alen = AbilityRule.valueLen(a);
|
|
769
1108
|
if (alen === null) {
|
|
770
1109
|
return false;
|
|
771
1110
|
}
|
|
772
|
-
if (
|
|
1111
|
+
if (AbilityRule.isNumber(b)) {
|
|
773
1112
|
return alen === b;
|
|
774
1113
|
}
|
|
775
|
-
const bLen =
|
|
1114
|
+
const bLen = AbilityRule.valueLen(b);
|
|
776
1115
|
if (bLen !== null) {
|
|
777
1116
|
return alen === bLen;
|
|
778
1117
|
}
|
|
@@ -785,8 +1124,12 @@ class AbilityRule {
|
|
|
785
1124
|
* @param environment
|
|
786
1125
|
*/
|
|
787
1126
|
check(resource, environment) {
|
|
1127
|
+
if (this.disabled) {
|
|
1128
|
+
this.state = AbilityMatch.disabled;
|
|
1129
|
+
return this.state;
|
|
1130
|
+
}
|
|
788
1131
|
const [subjectValue, resourceValue] = this.extractValues(resource, environment);
|
|
789
|
-
const handler =
|
|
1132
|
+
const handler = AbilityRule.operatorHandlers[toLiteral(this.condition)];
|
|
790
1133
|
const result = handler(subjectValue, resourceValue);
|
|
791
1134
|
this.state = result ? AbilityMatch.match : AbilityMatch.mismatch;
|
|
792
1135
|
return this.state;
|
|
@@ -833,46 +1176,75 @@ class AbilityRule {
|
|
|
833
1176
|
}
|
|
834
1177
|
return [subjectValue, resourceValue];
|
|
835
1178
|
}
|
|
1179
|
+
static _pathCache = new Map();
|
|
1180
|
+
static _parsePath(desc) {
|
|
1181
|
+
const cached = AbilityRule._pathCache.get(desc);
|
|
1182
|
+
if (cached)
|
|
1183
|
+
return cached;
|
|
1184
|
+
const parts = desc.split('.');
|
|
1185
|
+
const segments = [];
|
|
1186
|
+
for (const part of parts) {
|
|
1187
|
+
const bracketIdx = part.indexOf('[');
|
|
1188
|
+
if (bracketIdx !== -1) {
|
|
1189
|
+
// формат: "prop[index]" (индекс может быть только числом)
|
|
1190
|
+
const prop = part.slice(0, bracketIdx);
|
|
1191
|
+
const indexStr = part.slice(bracketIdx + 1, -1);
|
|
1192
|
+
const index = Number(indexStr);
|
|
1193
|
+
segments.push({ prop, index });
|
|
1194
|
+
}
|
|
1195
|
+
else {
|
|
1196
|
+
segments.push(part);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
AbilityRule._pathCache.set(desc, segments);
|
|
1200
|
+
return segments;
|
|
1201
|
+
}
|
|
836
1202
|
/**
|
|
837
1203
|
* Get the value of the object by dot notation
|
|
838
1204
|
* @param resource - The object to get the value from
|
|
839
1205
|
* @param desc - The dot notation string
|
|
840
1206
|
*/
|
|
841
1207
|
getDotNotationValue(resource, desc) {
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
}
|
|
854
|
-
else {
|
|
855
|
-
resource = undefined;
|
|
856
|
-
}
|
|
1208
|
+
if (resource == null) {
|
|
1209
|
+
return undefined;
|
|
1210
|
+
}
|
|
1211
|
+
const segments = AbilityRule._parsePath(desc);
|
|
1212
|
+
let current = resource;
|
|
1213
|
+
for (const seg of segments) {
|
|
1214
|
+
if (current == null) {
|
|
1215
|
+
return undefined;
|
|
1216
|
+
}
|
|
1217
|
+
if (typeof seg === 'string') {
|
|
1218
|
+
current = current[seg];
|
|
857
1219
|
}
|
|
858
1220
|
else {
|
|
859
|
-
|
|
1221
|
+
const arr = current[seg.prop];
|
|
1222
|
+
current = Array.isArray(arr) ? arr[seg.index] : undefined;
|
|
860
1223
|
}
|
|
861
1224
|
}
|
|
862
|
-
return
|
|
1225
|
+
return current;
|
|
863
1226
|
}
|
|
864
1227
|
toString() {
|
|
865
|
-
return `AbilityRule: ${this.name} condition: ${this.condition
|
|
1228
|
+
return `AbilityRule: ${this.name} condition: ${toLiteral(this.condition)} subject: "${this.subject?.toString()}" resource: "${this.resource?.toString()}"`;
|
|
866
1229
|
}
|
|
867
1230
|
copyWith(props) {
|
|
868
1231
|
return new AbilityRule({
|
|
869
1232
|
id: props.id ?? this.id,
|
|
870
1233
|
name: props.name ?? this.name,
|
|
1234
|
+
description: props.description ?? this.description,
|
|
871
1235
|
subject: props.subject ?? this.subject,
|
|
872
1236
|
resource: props.resource ?? this.resource,
|
|
873
1237
|
condition: props.condition ?? this.condition,
|
|
874
1238
|
});
|
|
875
1239
|
}
|
|
1240
|
+
hash() {
|
|
1241
|
+
const parts = [];
|
|
1242
|
+
parts.push(`subject:${this.subject}`);
|
|
1243
|
+
parts.push(`resource:${JSON.stringify(this.resource)}`);
|
|
1244
|
+
parts.push(`condition:${this.condition}`);
|
|
1245
|
+
parts.push(`disabled:${this.disabled}`);
|
|
1246
|
+
return AbilityHash.sha1(parts.join(';'));
|
|
1247
|
+
}
|
|
876
1248
|
static equals(subject, resource) {
|
|
877
1249
|
return new AbilityRule({
|
|
878
1250
|
condition: AbilityCondition.equals,
|
|
@@ -969,15 +1341,22 @@ class AbilityRuleSet {
|
|
|
969
1341
|
* Group name
|
|
970
1342
|
*/
|
|
971
1343
|
name;
|
|
1344
|
+
description;
|
|
972
1345
|
/**
|
|
973
1346
|
* Group ID
|
|
974
1347
|
*/
|
|
975
1348
|
id;
|
|
1349
|
+
isExcept = false;
|
|
1350
|
+
disabled;
|
|
976
1351
|
constructor(params) {
|
|
977
|
-
const { name, id, compareMethod } = params;
|
|
978
|
-
this.
|
|
979
|
-
this.id = id || this.name;
|
|
1352
|
+
const { name, id, compareMethod, isExcept, disabled, description } = params;
|
|
1353
|
+
this.description = description;
|
|
980
1354
|
this.compareMethod = compareMethod;
|
|
1355
|
+
this.isExcept = isExcept;
|
|
1356
|
+
this.disabled = typeof disabled === 'boolean' ? disabled : false;
|
|
1357
|
+
this.state = this.disabled ? AbilityMatch.disabled : this.state;
|
|
1358
|
+
this.id = id || `g_${this.hash().slice(0, 10)}`;
|
|
1359
|
+
this.name = name || this.id;
|
|
981
1360
|
}
|
|
982
1361
|
addRule(rule) {
|
|
983
1362
|
this.rules.push(rule);
|
|
@@ -989,40 +1368,48 @@ class AbilityRuleSet {
|
|
|
989
1368
|
}
|
|
990
1369
|
check(resources, environment) {
|
|
991
1370
|
this.state = AbilityMatch.mismatch;
|
|
1371
|
+
if (this.disabled) {
|
|
1372
|
+
this.state = AbilityMatch.disabled;
|
|
1373
|
+
return this.state;
|
|
1374
|
+
}
|
|
992
1375
|
if (!this.rules.length) {
|
|
993
1376
|
return this.state;
|
|
994
1377
|
}
|
|
995
1378
|
const ruleCheckStates = [];
|
|
996
1379
|
for (const rule of this.rules) {
|
|
1380
|
+
if (rule.disabled) {
|
|
1381
|
+
continue;
|
|
1382
|
+
}
|
|
997
1383
|
const state = rule.check(resources, environment);
|
|
998
1384
|
ruleCheckStates.push(state);
|
|
999
|
-
if (AbilityCompare.and
|
|
1385
|
+
if (AbilityCompare.and === this.compareMethod && AbilityMatch.mismatch === state) {
|
|
1000
1386
|
return this.state; // mismatch
|
|
1001
1387
|
}
|
|
1002
|
-
if (AbilityCompare.or
|
|
1388
|
+
if (AbilityCompare.or === this.compareMethod && AbilityMatch.match === state) {
|
|
1003
1389
|
this.state = AbilityMatch.match;
|
|
1004
1390
|
return this.state;
|
|
1005
1391
|
}
|
|
1006
1392
|
}
|
|
1007
|
-
if (AbilityCompare.and
|
|
1008
|
-
if (ruleCheckStates.every(s => AbilityMatch.match
|
|
1393
|
+
if (AbilityCompare.and === this.compareMethod) {
|
|
1394
|
+
if (ruleCheckStates.every(s => AbilityMatch.match === s)) {
|
|
1009
1395
|
this.state = AbilityMatch.match;
|
|
1010
1396
|
}
|
|
1011
1397
|
}
|
|
1012
|
-
if (AbilityCompare.or
|
|
1013
|
-
if (ruleCheckStates.some(s => AbilityMatch.match
|
|
1398
|
+
if (AbilityCompare.or === this.compareMethod) {
|
|
1399
|
+
if (ruleCheckStates.some(s => AbilityMatch.match === s)) {
|
|
1014
1400
|
this.state = AbilityMatch.match;
|
|
1015
1401
|
}
|
|
1016
1402
|
}
|
|
1017
1403
|
return this.state;
|
|
1018
1404
|
}
|
|
1019
1405
|
toString() {
|
|
1020
|
-
return `AbilityRuleSet: ${this.name} compareMethod: ${this.compareMethod
|
|
1406
|
+
return `AbilityRuleSet: ${this.name} compareMethod: ${this.compareMethod}, rules: ${this.rules.map(rule => rule.toString()).join('\n')}`;
|
|
1021
1407
|
}
|
|
1022
1408
|
copyWith(props) {
|
|
1023
1409
|
const next = new AbilityRuleSet({
|
|
1024
1410
|
id: props.id ?? this.id,
|
|
1025
1411
|
name: props.name ?? this.name,
|
|
1412
|
+
description: props.description ?? this.description,
|
|
1026
1413
|
compareMethod: props.compareMethod ?? this.compareMethod,
|
|
1027
1414
|
});
|
|
1028
1415
|
const nextRules = props.rules ?? this.rules;
|
|
@@ -1031,6 +1418,16 @@ class AbilityRuleSet {
|
|
|
1031
1418
|
}
|
|
1032
1419
|
return next;
|
|
1033
1420
|
}
|
|
1421
|
+
hash() {
|
|
1422
|
+
const ruleHashes = this.rules.map(r => r.hash()).sort();
|
|
1423
|
+
const parts = [
|
|
1424
|
+
`compareMethod:${this.compareMethod}`,
|
|
1425
|
+
`isExcept:${this.isExcept}`,
|
|
1426
|
+
`disabled:${this.disabled}`,
|
|
1427
|
+
`rules:${ruleHashes.join('|')}`,
|
|
1428
|
+
];
|
|
1429
|
+
return AbilityHash.sha1(parts.join(';'));
|
|
1430
|
+
}
|
|
1034
1431
|
static and(rules) {
|
|
1035
1432
|
return new AbilityRuleSet({
|
|
1036
1433
|
compareMethod: AbilityCompare.and,
|
|
@@ -1053,37 +1450,42 @@ class AbilityJSONParser {
|
|
|
1053
1450
|
return configs.map(config => AbilityJSONParser.parsePolicy(config));
|
|
1054
1451
|
}
|
|
1055
1452
|
static parsePolicy(config) {
|
|
1056
|
-
const { id, name, ruleSet, compareMethod, permission, effect } = config;
|
|
1453
|
+
const { id, name, ruleSet, compareMethod, permission, effect, priority, disabled, tags } = config;
|
|
1057
1454
|
// Create the empty policy
|
|
1058
1455
|
const policy = new AbilityPolicy({
|
|
1059
1456
|
name,
|
|
1060
1457
|
id,
|
|
1061
1458
|
permission: permission,
|
|
1062
|
-
|
|
1459
|
+
priority: priority,
|
|
1460
|
+
effect: effect,
|
|
1461
|
+
disabled,
|
|
1462
|
+
tags,
|
|
1063
1463
|
});
|
|
1064
|
-
policy.compareMethod =
|
|
1464
|
+
policy.compareMethod = compareMethod;
|
|
1065
1465
|
ruleSet.forEach(ruleSetConfig => {
|
|
1066
1466
|
policy.addRuleSet(AbilityJSONParser.parseRuleSet(ruleSetConfig));
|
|
1067
1467
|
});
|
|
1068
1468
|
return policy;
|
|
1069
1469
|
}
|
|
1070
1470
|
static parseRule(config) {
|
|
1071
|
-
const { id, name, subject, resource, condition } = config;
|
|
1471
|
+
const { id, name, subject, resource, condition, disabled } = config;
|
|
1072
1472
|
return new AbilityRule({
|
|
1073
1473
|
id,
|
|
1074
1474
|
name,
|
|
1075
1475
|
subject,
|
|
1076
1476
|
resource,
|
|
1077
|
-
|
|
1477
|
+
disabled,
|
|
1478
|
+
condition,
|
|
1078
1479
|
});
|
|
1079
1480
|
}
|
|
1080
1481
|
/**
|
|
1081
1482
|
* Parse the config JSON format to Group class instance
|
|
1082
1483
|
*/
|
|
1083
1484
|
static parseRuleSet(config) {
|
|
1084
|
-
const { id, name, rules, compareMethod } = config;
|
|
1485
|
+
const { id, name, rules, compareMethod, disabled } = config;
|
|
1085
1486
|
const ruleSet = new AbilityRuleSet({
|
|
1086
|
-
|
|
1487
|
+
disabled,
|
|
1488
|
+
compareMethod: compareMethod,
|
|
1087
1489
|
name,
|
|
1088
1490
|
id,
|
|
1089
1491
|
});
|
|
@@ -1100,25 +1502,30 @@ class AbilityJSONParser {
|
|
|
1100
1502
|
name: rule.name,
|
|
1101
1503
|
subject: rule.subject,
|
|
1102
1504
|
resource: rule.resource,
|
|
1103
|
-
condition: rule.condition
|
|
1505
|
+
condition: rule.condition,
|
|
1506
|
+
disabled: rule.disabled,
|
|
1104
1507
|
};
|
|
1105
1508
|
}
|
|
1106
1509
|
static ruleSetToJSON(ruleSet) {
|
|
1107
1510
|
return {
|
|
1108
1511
|
id: ruleSet.id.toString(),
|
|
1109
1512
|
name: ruleSet.name.toString(),
|
|
1110
|
-
compareMethod: ruleSet.compareMethod
|
|
1513
|
+
compareMethod: ruleSet.compareMethod,
|
|
1111
1514
|
rules: ruleSet.rules.map(rule => AbilityJSONParser.ruleToJSON(rule)),
|
|
1515
|
+
disabled: ruleSet.disabled,
|
|
1112
1516
|
};
|
|
1113
1517
|
}
|
|
1114
1518
|
static policyToJSON(policy) {
|
|
1115
1519
|
return {
|
|
1116
1520
|
id: policy.id.toString(),
|
|
1117
1521
|
name: policy.name.toString(),
|
|
1118
|
-
compareMethod: policy.compareMethod
|
|
1522
|
+
compareMethod: policy.compareMethod,
|
|
1119
1523
|
ruleSet: policy.ruleSet.map(ruleSet => AbilityJSONParser.ruleSetToJSON(ruleSet)),
|
|
1120
1524
|
permission: policy.permission,
|
|
1121
|
-
effect: policy.effect
|
|
1525
|
+
effect: policy.effect,
|
|
1526
|
+
priority: policy.priority,
|
|
1527
|
+
disabled: policy.disabled,
|
|
1528
|
+
tags: policy.tags,
|
|
1122
1529
|
};
|
|
1123
1530
|
}
|
|
1124
1531
|
static toJSON(policies) {
|
|
@@ -1126,67 +1533,66 @@ class AbilityJSONParser {
|
|
|
1126
1533
|
}
|
|
1127
1534
|
}
|
|
1128
1535
|
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1536
|
+
function brand(code) {
|
|
1537
|
+
return code;
|
|
1538
|
+
}
|
|
1539
|
+
const TokenTypes = {
|
|
1540
|
+
EFFECT: brand('EFFECT'),
|
|
1541
|
+
IF: brand('IF'),
|
|
1542
|
+
PERMISSION: brand('PERMISSION'),
|
|
1543
|
+
IDENTIFIER: brand('IDENTIFIER'),
|
|
1544
|
+
COLON: brand('COLON'),
|
|
1545
|
+
COMMA: brand('COMMA'),
|
|
1546
|
+
DOT: brand('DOT'),
|
|
1547
|
+
LBRACKET: brand('LBRACKET'),
|
|
1548
|
+
RBRACKET: brand('RBRACKET'),
|
|
1549
|
+
ALL: brand('ALL'),
|
|
1550
|
+
ANY: brand('ANY'),
|
|
1551
|
+
OF: brand('OF'),
|
|
1552
|
+
EOF: brand('EOF'),
|
|
1553
|
+
COMMENT: brand('COMMENT'),
|
|
1554
|
+
EQ: brand('EQ'),
|
|
1555
|
+
CONTAINS: brand('CONTAINS'),
|
|
1556
|
+
IN: brand('IN'),
|
|
1557
|
+
NOT_IN: brand('NOT_IN'),
|
|
1558
|
+
NOT_CONTAINS: brand('NOT_CONTAINS'),
|
|
1559
|
+
GT: brand('GT'),
|
|
1560
|
+
GTE: brand('GTE'),
|
|
1561
|
+
LT: brand('LT'),
|
|
1562
|
+
LTE: brand('LTE'),
|
|
1563
|
+
NULL: brand('NULL'),
|
|
1564
|
+
EQ_NULL: brand('EQ_NULL'),
|
|
1565
|
+
NOT_EQ_NULL: brand('NOT_EQ_NULL'),
|
|
1566
|
+
NOT_EQ: brand('NOT_EQ'),
|
|
1567
|
+
LEN_GT: brand('LEN_GT'),
|
|
1568
|
+
LEN_LT: brand('LEN_LT'),
|
|
1569
|
+
LEN_EQ: brand('LEN_EQ'),
|
|
1570
|
+
ALWAYS: brand('ALWAYS'),
|
|
1571
|
+
NEVER: brand('NEVER'),
|
|
1572
|
+
EXCEPT: brand('EXCEPT'),
|
|
1573
|
+
ANNOTATION: brand('ANNOTATION'),
|
|
1574
|
+
STRING: brand('STRING'),
|
|
1575
|
+
NUMBER: brand('NUMBER'),
|
|
1576
|
+
BOOLEAN: brand('BOOLEAN'),
|
|
1577
|
+
SYMBOL: brand('SYMBOL'),
|
|
1578
|
+
KEYWORD: brand('KEYWORD'),
|
|
1579
|
+
ALIAS: brand('ALIAS'),
|
|
1580
|
+
UNKNOWN: brand('UNKNOWN'),
|
|
1581
|
+
};
|
|
1582
|
+
class AbilityDSLToken {
|
|
1583
|
+
type;
|
|
1584
|
+
value;
|
|
1585
|
+
line;
|
|
1586
|
+
column;
|
|
1140
1587
|
constructor(type, value, line, column) {
|
|
1141
|
-
|
|
1588
|
+
this.type = type;
|
|
1142
1589
|
this.value = value;
|
|
1143
1590
|
this.line = line;
|
|
1144
1591
|
this.column = column;
|
|
1145
1592
|
}
|
|
1146
|
-
/**
|
|
1147
|
-
* Returns a human-readable representation of the token, useful for debugging.
|
|
1148
|
-
* Example output: "AbilityDSLToken([EFFECT] permit"
|
|
1149
|
-
*/
|
|
1150
1593
|
toString() {
|
|
1151
|
-
return `AbilityDSLToken([${this.
|
|
1152
|
-
}
|
|
1153
|
-
static EFFECT = 'EFFECT';
|
|
1154
|
-
static IF = 'IF';
|
|
1155
|
-
static PERMISSION = 'PERMISSION';
|
|
1156
|
-
static IDENTIFIER = 'IDENTIFIER';
|
|
1157
|
-
static COLON = 'COLON';
|
|
1158
|
-
static COMMA = 'COMMA';
|
|
1159
|
-
static DOT = 'DOT';
|
|
1160
|
-
static LBRACKET = 'LBRACKET';
|
|
1161
|
-
static RBRACKET = 'RBRACKET';
|
|
1162
|
-
static ALL = 'ALL';
|
|
1163
|
-
static ANY = 'ANY';
|
|
1164
|
-
static OF = 'OF';
|
|
1165
|
-
static EOF = 'EOF';
|
|
1166
|
-
static COMMENT = 'COMMENT';
|
|
1167
|
-
static EQ = 'EQ';
|
|
1168
|
-
static CONTAINS = 'CONTAINS';
|
|
1169
|
-
static IN = 'IN';
|
|
1170
|
-
static NOT_IN = 'NOT_IN';
|
|
1171
|
-
static NOT_CONTAINS = 'NOT_CONTAINS';
|
|
1172
|
-
static GT = 'GT';
|
|
1173
|
-
static GTE = 'GTE';
|
|
1174
|
-
static LT = 'LT';
|
|
1175
|
-
static LTE = 'LTE';
|
|
1176
|
-
static NULL = 'NULL';
|
|
1177
|
-
static EQ_NULL = 'EQ_NULL';
|
|
1178
|
-
static NOT_EQ_NULL = 'NOT_EQ_NULL';
|
|
1179
|
-
static LEN_GT = 'LEN_GT';
|
|
1180
|
-
static LEN_LT = 'LEN_LT';
|
|
1181
|
-
static LEN_EQ = 'LEN_EQ';
|
|
1182
|
-
static NOT_EQ = 'NOT_EQ';
|
|
1183
|
-
static ALWAYS = 'ALWAYS';
|
|
1184
|
-
static NEVER = 'NEVER';
|
|
1185
|
-
static STRING = 'STRING';
|
|
1186
|
-
static NUMBER = 'NUMBER';
|
|
1187
|
-
static BOOLEAN = 'BOOLEAN';
|
|
1188
|
-
static SYMBOL = 'SYMBOL';
|
|
1189
|
-
static KEYWORD = 'KEYWORD';
|
|
1594
|
+
return `AbilityDSLToken([${this.type}] "${this.value}" at ${this.line}:${this.column})`;
|
|
1595
|
+
}
|
|
1190
1596
|
}
|
|
1191
1597
|
|
|
1192
1598
|
class AbilityDSLLexer {
|
|
@@ -1195,7 +1601,6 @@ class AbilityDSLLexer {
|
|
|
1195
1601
|
tokens = [];
|
|
1196
1602
|
line = 1;
|
|
1197
1603
|
column = 1;
|
|
1198
|
-
// Список ключевых слов
|
|
1199
1604
|
keywords = new Set([
|
|
1200
1605
|
'if',
|
|
1201
1606
|
'all',
|
|
@@ -1226,6 +1631,8 @@ class AbilityDSLLexer {
|
|
|
1226
1631
|
'than',
|
|
1227
1632
|
'always',
|
|
1228
1633
|
'never',
|
|
1634
|
+
'except',
|
|
1635
|
+
'alias',
|
|
1229
1636
|
]);
|
|
1230
1637
|
constructor(input) {
|
|
1231
1638
|
this.input = input;
|
|
@@ -1236,6 +1643,10 @@ class AbilityDSLLexer {
|
|
|
1236
1643
|
if (this.isAtEnd())
|
|
1237
1644
|
break;
|
|
1238
1645
|
const char = this.peek();
|
|
1646
|
+
if (char === '@') {
|
|
1647
|
+
this.tokens.push(this.readAnnotation());
|
|
1648
|
+
continue;
|
|
1649
|
+
}
|
|
1239
1650
|
if (char === '#') {
|
|
1240
1651
|
this.tokens.push(this.readComment());
|
|
1241
1652
|
continue;
|
|
@@ -1258,7 +1669,7 @@ class AbilityDSLLexer {
|
|
|
1258
1669
|
}
|
|
1259
1670
|
throw new Error(`Unexpected character '${char}' at ${this.line}:${this.column}`);
|
|
1260
1671
|
}
|
|
1261
|
-
this.tokens.push(new AbilityDSLToken(
|
|
1672
|
+
this.tokens.push(new AbilityDSLToken(TokenTypes.EOF, '', this.line, this.column));
|
|
1262
1673
|
return this.tokens;
|
|
1263
1674
|
}
|
|
1264
1675
|
readComment() {
|
|
@@ -1269,7 +1680,69 @@ class AbilityDSLLexer {
|
|
|
1269
1680
|
while (!this.isAtEnd() && !this.isNewline()) {
|
|
1270
1681
|
value += this.advance();
|
|
1271
1682
|
}
|
|
1272
|
-
return new AbilityDSLToken(
|
|
1683
|
+
return new AbilityDSLToken(TokenTypes.COMMENT, value.trim(), startLine, startColumn);
|
|
1684
|
+
}
|
|
1685
|
+
// private readAlias(): AbilityDSLToken {
|
|
1686
|
+
// const startLine = this.line;
|
|
1687
|
+
// const startColumn = this.column;
|
|
1688
|
+
//
|
|
1689
|
+
// this.advance(); // skip "alias" keyword
|
|
1690
|
+
//
|
|
1691
|
+
// // Read colon
|
|
1692
|
+
// this.readSymbol();
|
|
1693
|
+
//
|
|
1694
|
+
// let value = '';
|
|
1695
|
+
// while (!this.isAtEnd() && !this.isNewline()) {
|
|
1696
|
+
// value += this.advance();
|
|
1697
|
+
// }
|
|
1698
|
+
// return new AbilityDSLToken(TokenTypes.ALIAS, value.trim(), startLine, startColumn);
|
|
1699
|
+
// }
|
|
1700
|
+
readAnnotation() {
|
|
1701
|
+
const startLine = this.line;
|
|
1702
|
+
const startColumn = this.column;
|
|
1703
|
+
let raw = '';
|
|
1704
|
+
// читаем всю строку после @
|
|
1705
|
+
while (!this.isAtEnd() && !this.isNewline()) {
|
|
1706
|
+
raw += this.advance();
|
|
1707
|
+
}
|
|
1708
|
+
raw = raw.trim();
|
|
1709
|
+
// parse literals
|
|
1710
|
+
let result = '';
|
|
1711
|
+
let i = 0;
|
|
1712
|
+
let inString = false;
|
|
1713
|
+
let quote = null;
|
|
1714
|
+
let escaped = false;
|
|
1715
|
+
while (i < raw.length) {
|
|
1716
|
+
const ch = raw[i];
|
|
1717
|
+
if (inString) {
|
|
1718
|
+
if (escaped) {
|
|
1719
|
+
result += ch;
|
|
1720
|
+
escaped = false;
|
|
1721
|
+
}
|
|
1722
|
+
else if (ch === '\\') {
|
|
1723
|
+
escaped = true;
|
|
1724
|
+
}
|
|
1725
|
+
else if (ch === quote) {
|
|
1726
|
+
inString = false;
|
|
1727
|
+
quote = null;
|
|
1728
|
+
}
|
|
1729
|
+
else {
|
|
1730
|
+
result += ch;
|
|
1731
|
+
}
|
|
1732
|
+
i++;
|
|
1733
|
+
continue;
|
|
1734
|
+
}
|
|
1735
|
+
// start of string
|
|
1736
|
+
if (ch === '"' || ch === "'") {
|
|
1737
|
+
inString = true;
|
|
1738
|
+
quote = ch;
|
|
1739
|
+
i++;
|
|
1740
|
+
continue;
|
|
1741
|
+
}
|
|
1742
|
+
result += ch;
|
|
1743
|
+
i++;
|
|
1744
|
+
}
|
|
1745
|
+
return new AbilityDSLToken(TokenTypes.ANNOTATION, result.trim(), startLine, startColumn);
|
|
1273
1746
|
}
|
|
1274
1747
|
readString() {
|
|
1275
1748
|
const startLine = this.line;
|
|
@@ -1289,7 +1762,7 @@ class AbilityDSLLexer {
|
|
|
1289
1762
|
continue;
|
|
1290
1763
|
}
|
|
1291
1764
|
if (char === quote) {
|
|
1292
|
-
return new AbilityDSLToken(
|
|
1765
|
+
return new AbilityDSLToken(TokenTypes.STRING, value, startLine, startColumn);
|
|
1293
1766
|
}
|
|
1294
1767
|
value += char;
|
|
1295
1768
|
}
|
|
@@ -1303,7 +1776,7 @@ class AbilityDSLLexer {
|
|
|
1303
1776
|
this.advance();
|
|
1304
1777
|
}
|
|
1305
1778
|
const value = this.input.slice(start, this.pos);
|
|
1306
|
-
return new AbilityDSLToken(
|
|
1779
|
+
return new AbilityDSLToken(TokenTypes.NUMBER, value, startLine, startColumn);
|
|
1307
1780
|
}
|
|
1308
1781
|
readSymbol() {
|
|
1309
1782
|
const startLine = this.line;
|
|
@@ -1311,41 +1784,41 @@ class AbilityDSLLexer {
|
|
|
1311
1784
|
const char = this.advance();
|
|
1312
1785
|
switch (char) {
|
|
1313
1786
|
case '.':
|
|
1314
|
-
return new AbilityDSLToken(
|
|
1787
|
+
return new AbilityDSLToken(TokenTypes.DOT, char, startLine, startColumn);
|
|
1315
1788
|
case ':':
|
|
1316
|
-
return new AbilityDSLToken(
|
|
1789
|
+
return new AbilityDSLToken(TokenTypes.COLON, char, startLine, startColumn);
|
|
1317
1790
|
case ',':
|
|
1318
|
-
return new AbilityDSLToken(
|
|
1791
|
+
return new AbilityDSLToken(TokenTypes.COMMA, char, startLine, startColumn);
|
|
1319
1792
|
case '[':
|
|
1320
|
-
return new AbilityDSLToken(
|
|
1793
|
+
return new AbilityDSLToken(TokenTypes.LBRACKET, char, startLine, startColumn);
|
|
1321
1794
|
case ']':
|
|
1322
|
-
return new AbilityDSLToken(
|
|
1795
|
+
return new AbilityDSLToken(TokenTypes.RBRACKET, char, startLine, startColumn);
|
|
1323
1796
|
case '>':
|
|
1324
1797
|
if (this.peek() === '=') {
|
|
1325
1798
|
this.advance();
|
|
1326
|
-
return new AbilityDSLToken(
|
|
1799
|
+
return new AbilityDSLToken(TokenTypes.SYMBOL, '>=', startLine, startColumn);
|
|
1327
1800
|
}
|
|
1328
|
-
return new AbilityDSLToken(
|
|
1801
|
+
return new AbilityDSLToken(TokenTypes.SYMBOL, '>', startLine, startColumn);
|
|
1329
1802
|
case '<':
|
|
1330
1803
|
if (this.peek() === '=') {
|
|
1331
1804
|
this.advance();
|
|
1332
|
-
return new AbilityDSLToken(
|
|
1805
|
+
return new AbilityDSLToken(TokenTypes.SYMBOL, '<=', startLine, startColumn);
|
|
1333
1806
|
}
|
|
1334
1807
|
if (this.peek() === '>') {
|
|
1335
1808
|
this.advance();
|
|
1336
|
-
return new AbilityDSLToken(
|
|
1809
|
+
return new AbilityDSLToken(TokenTypes.SYMBOL, '<>', startLine, startColumn);
|
|
1337
1810
|
}
|
|
1338
|
-
return new AbilityDSLToken(
|
|
1811
|
+
return new AbilityDSLToken(TokenTypes.SYMBOL, '<', startLine, startColumn);
|
|
1339
1812
|
case '=':
|
|
1340
1813
|
if (this.peek() === '=') {
|
|
1341
1814
|
this.advance();
|
|
1342
|
-
return new AbilityDSLToken(
|
|
1815
|
+
return new AbilityDSLToken(TokenTypes.SYMBOL, '==', startLine, startColumn);
|
|
1343
1816
|
}
|
|
1344
|
-
return new AbilityDSLToken(
|
|
1817
|
+
return new AbilityDSLToken(TokenTypes.SYMBOL, '=', startLine, startColumn);
|
|
1345
1818
|
case '!':
|
|
1346
1819
|
if (this.peek() === '=') {
|
|
1347
1820
|
this.advance();
|
|
1348
|
-
return new AbilityDSLToken(
|
|
1821
|
+
return new AbilityDSLToken(TokenTypes.SYMBOL, '!=', startLine, startColumn);
|
|
1349
1822
|
}
|
|
1350
1823
|
throw new Error(`Unexpected symbol '!' at ${this.line}:${this.column}`);
|
|
1351
1824
|
default:
|
|
@@ -1372,60 +1845,66 @@ class AbilityDSLLexer {
|
|
|
1372
1845
|
}
|
|
1373
1846
|
const word = this.input.slice(start, this.pos);
|
|
1374
1847
|
if (word === 'always') {
|
|
1375
|
-
return new AbilityDSLToken(
|
|
1848
|
+
return new AbilityDSLToken(TokenTypes.ALWAYS, word, startLine, startColumn);
|
|
1376
1849
|
}
|
|
1377
1850
|
if (word === 'never') {
|
|
1378
|
-
return new AbilityDSLToken(
|
|
1851
|
+
return new AbilityDSLToken(TokenTypes.NEVER, word, startLine, startColumn);
|
|
1379
1852
|
}
|
|
1380
1853
|
// Если есть точка — это путь (identifier или permission)
|
|
1381
1854
|
if (word.includes('.')) {
|
|
1382
1855
|
const last = this.tokens[this.tokens.length - 1];
|
|
1383
|
-
if (last?.
|
|
1856
|
+
if (last?.type === TokenTypes.EFFECT) {
|
|
1384
1857
|
if (word.startsWith('permission.')) {
|
|
1385
|
-
return new AbilityDSLToken(
|
|
1858
|
+
return new AbilityDSLToken(TokenTypes.PERMISSION, word, startLine, startColumn);
|
|
1386
1859
|
}
|
|
1387
1860
|
}
|
|
1388
|
-
return new AbilityDSLToken(
|
|
1861
|
+
return new AbilityDSLToken(TokenTypes.IDENTIFIER, word, startLine, startColumn);
|
|
1389
1862
|
}
|
|
1390
1863
|
// Ключевые слова
|
|
1391
1864
|
if (this.keywords.has(word)) {
|
|
1392
1865
|
// Эффекты
|
|
1393
1866
|
if (word === 'permit' || word === 'allow') {
|
|
1394
|
-
return new AbilityDSLToken(
|
|
1867
|
+
return new AbilityDSLToken(TokenTypes.EFFECT, 'permit', startLine, startColumn);
|
|
1395
1868
|
}
|
|
1396
1869
|
if (word === 'deny' || word === 'forbidden') {
|
|
1397
|
-
return new AbilityDSLToken(
|
|
1870
|
+
return new AbilityDSLToken(TokenTypes.EFFECT, 'deny', startLine, startColumn);
|
|
1398
1871
|
}
|
|
1399
1872
|
// Групповые ключевые слова
|
|
1400
1873
|
if (word === 'all') {
|
|
1401
|
-
return new AbilityDSLToken(
|
|
1874
|
+
return new AbilityDSLToken(TokenTypes.ALL, word, startLine, startColumn);
|
|
1402
1875
|
}
|
|
1403
1876
|
if (word === 'any') {
|
|
1404
|
-
return new AbilityDSLToken(
|
|
1877
|
+
return new AbilityDSLToken(TokenTypes.ANY, word, startLine, startColumn);
|
|
1405
1878
|
}
|
|
1406
1879
|
if (word === 'of') {
|
|
1407
|
-
return new AbilityDSLToken(
|
|
1880
|
+
return new AbilityDSLToken(TokenTypes.OF, word, startLine, startColumn);
|
|
1408
1881
|
}
|
|
1409
1882
|
if (word === 'if') {
|
|
1410
|
-
return new AbilityDSLToken(
|
|
1883
|
+
return new AbilityDSLToken(TokenTypes.IF, word, startLine, startColumn);
|
|
1411
1884
|
}
|
|
1412
1885
|
// Булевы и null
|
|
1413
1886
|
if (word === 'true' || word === 'false') {
|
|
1414
|
-
return new AbilityDSLToken(
|
|
1887
|
+
return new AbilityDSLToken(TokenTypes.BOOLEAN, word, startLine, startColumn);
|
|
1415
1888
|
}
|
|
1416
1889
|
if (word === 'null') {
|
|
1417
|
-
return new AbilityDSLToken(
|
|
1890
|
+
return new AbilityDSLToken(TokenTypes.NULL, word, startLine, startColumn);
|
|
1891
|
+
}
|
|
1892
|
+
if (word === 'except') {
|
|
1893
|
+
return new AbilityDSLToken(TokenTypes.EXCEPT, word, startLine, startColumn);
|
|
1894
|
+
}
|
|
1895
|
+
if (word === 'alias') {
|
|
1896
|
+
return new AbilityDSLToken(TokenTypes.ALIAS, word, startLine, startColumn);
|
|
1418
1897
|
}
|
|
1419
1898
|
// Остальные ключевые слова (contains, in, equals, greater, less, not, is, or, than, equal)
|
|
1420
|
-
return new AbilityDSLToken(
|
|
1899
|
+
return new AbilityDSLToken(TokenTypes.KEYWORD, word, startLine, startColumn);
|
|
1421
1900
|
}
|
|
1422
1901
|
// Если после EFFECT и нет точки — действие (например, "create")
|
|
1423
1902
|
const lastToken = this.tokens[this.tokens.length - 1];
|
|
1424
|
-
if (lastToken?.
|
|
1425
|
-
return new AbilityDSLToken(
|
|
1903
|
+
if (lastToken?.type === TokenTypes.EFFECT) {
|
|
1904
|
+
return new AbilityDSLToken(TokenTypes.PERMISSION, word, startLine, startColumn);
|
|
1426
1905
|
}
|
|
1427
1906
|
// Обычный идентификатор
|
|
1428
|
-
return new AbilityDSLToken(
|
|
1907
|
+
return new AbilityDSLToken(TokenTypes.IDENTIFIER, word, startLine, startColumn);
|
|
1429
1908
|
}
|
|
1430
1909
|
skipWhitespace() {
|
|
1431
1910
|
while (!this.isAtEnd() && /\s/.test(this.peek())) {
|
|
@@ -1481,47 +1960,256 @@ class AbilityDSLSyntaxError extends Error {
|
|
|
1481
1960
|
if (Error.captureStackTrace) {
|
|
1482
1961
|
Error.captureStackTrace(this, AbilityDSLSyntaxError);
|
|
1483
1962
|
}
|
|
1484
|
-
this._originalStack = this.stack;
|
|
1485
|
-
this._formattedMessage = this.formatMessage();
|
|
1486
|
-
Object.defineProperty(this, 'stack', {
|
|
1487
|
-
get: () => this._formattedMessage,
|
|
1488
|
-
configurable: true,
|
|
1489
|
-
});
|
|
1963
|
+
this._originalStack = this.stack;
|
|
1964
|
+
this._formattedMessage = this.formatMessage();
|
|
1965
|
+
Object.defineProperty(this, 'stack', {
|
|
1966
|
+
get: () => this._formattedMessage,
|
|
1967
|
+
configurable: true,
|
|
1968
|
+
});
|
|
1969
|
+
}
|
|
1970
|
+
static supportsColor() {
|
|
1971
|
+
return typeof process !== 'undefined' && process.stdout?.isTTY;
|
|
1972
|
+
}
|
|
1973
|
+
formatMessage() {
|
|
1974
|
+
const useColor = AbilityDSLSyntaxError.supportsColor();
|
|
1975
|
+
const BOLD = useColor ? '\x1b[1m' : '';
|
|
1976
|
+
const RED = useColor ? '\x1b[31m' : '';
|
|
1977
|
+
const ORANGE = useColor ? '\x1b[33;1m' : '';
|
|
1978
|
+
const GRAY = useColor ? '\x1b[90m' : '';
|
|
1979
|
+
const RESET = useColor ? '\x1b[0m' : '';
|
|
1980
|
+
const lines = this.context.split('\n');
|
|
1981
|
+
// Find line with ^
|
|
1982
|
+
const pointerIndex = lines.findIndex(l => l.includes('^') || l.includes('~'));
|
|
1983
|
+
const commentIndex = lines.findIndex(l => l.trim().includes('#'));
|
|
1984
|
+
const formattedLines = lines.map((line, idx) => {
|
|
1985
|
+
if (idx === pointerIndex - 1) {
|
|
1986
|
+
// Error line
|
|
1987
|
+
return `${BOLD}${ORANGE}${line}${RESET}`;
|
|
1988
|
+
}
|
|
1989
|
+
if (idx === pointerIndex) {
|
|
1990
|
+
// Error with ~~~~~
|
|
1991
|
+
return `${RED}${line}${RESET}`;
|
|
1992
|
+
}
|
|
1993
|
+
// Comments # ...
|
|
1994
|
+
if (idx === commentIndex) {
|
|
1995
|
+
return `${GRAY}${line}${RESET}`;
|
|
1996
|
+
}
|
|
1997
|
+
return line;
|
|
1998
|
+
});
|
|
1999
|
+
const contextBlock = formattedLines.join('\n');
|
|
2000
|
+
return `${BOLD}${RED}${this.name}: ${this.details}${RESET}\n\n` + contextBlock;
|
|
2001
|
+
}
|
|
2002
|
+
toString() {
|
|
2003
|
+
return this._formattedMessage;
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
class AbilityDSLTokenStream {
|
|
2008
|
+
tokens;
|
|
2009
|
+
pos = 0;
|
|
2010
|
+
dsl;
|
|
2011
|
+
marks = [];
|
|
2012
|
+
lastToken = null;
|
|
2013
|
+
next() {
|
|
2014
|
+
const token = this.tokens[this.pos++];
|
|
2015
|
+
this.lastToken = token;
|
|
2016
|
+
return token;
|
|
2017
|
+
}
|
|
2018
|
+
prev() {
|
|
2019
|
+
if (this.pos === 0) {
|
|
2020
|
+
return null;
|
|
2021
|
+
}
|
|
2022
|
+
const token = this.tokens[this.pos--];
|
|
2023
|
+
this.lastToken = token;
|
|
2024
|
+
return token;
|
|
2025
|
+
}
|
|
2026
|
+
lookPrev() {
|
|
2027
|
+
return this.lastToken;
|
|
2028
|
+
}
|
|
2029
|
+
constructor(tokens, dsl) {
|
|
2030
|
+
this.tokens = tokens;
|
|
2031
|
+
this.dsl = dsl;
|
|
2032
|
+
}
|
|
2033
|
+
peek() {
|
|
2034
|
+
return this.tokens[this.pos];
|
|
2035
|
+
}
|
|
2036
|
+
eof() {
|
|
2037
|
+
return this.peek().type === TokenTypes.EOF;
|
|
2038
|
+
}
|
|
2039
|
+
check(type) {
|
|
2040
|
+
if (this.eof()) {
|
|
2041
|
+
return false;
|
|
2042
|
+
}
|
|
2043
|
+
return this.peek().type === type;
|
|
2044
|
+
}
|
|
2045
|
+
match(type) {
|
|
2046
|
+
if (this.check(type)) {
|
|
2047
|
+
return this.next();
|
|
2048
|
+
}
|
|
2049
|
+
return null;
|
|
2050
|
+
}
|
|
2051
|
+
expect(type, message) {
|
|
2052
|
+
const token = this.peek();
|
|
2053
|
+
if (token && token.type === type) {
|
|
2054
|
+
return this.next();
|
|
2055
|
+
}
|
|
2056
|
+
this.syntaxError(message, token, [type]);
|
|
2057
|
+
}
|
|
2058
|
+
expectOneOf(types, message) {
|
|
2059
|
+
const token = this.peek();
|
|
2060
|
+
for (const t of types) {
|
|
2061
|
+
if (token && token.type === t) {
|
|
2062
|
+
return this.next();
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
this.syntaxError(message, token, types);
|
|
2066
|
+
}
|
|
2067
|
+
mark() {
|
|
2068
|
+
this.marks.push(this.pos);
|
|
2069
|
+
}
|
|
2070
|
+
reset() {
|
|
2071
|
+
const pos = this.marks.pop();
|
|
2072
|
+
if (pos !== undefined) {
|
|
2073
|
+
this.pos = pos;
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
commit() {
|
|
2077
|
+
this.marks.pop();
|
|
2078
|
+
}
|
|
2079
|
+
syntaxError(details, token, expected) {
|
|
2080
|
+
const lines = this.dsl.split(/\r?\n/);
|
|
2081
|
+
const lineIdx = token.line - 1;
|
|
2082
|
+
const lineBefore = lineIdx > 0 ? lines[lineIdx - 1] : '';
|
|
2083
|
+
const current = lines[lineIdx];
|
|
2084
|
+
const linesAfter = lineIdx + 1 < lines.length ? lines[lineIdx + 1] : '';
|
|
2085
|
+
const wave = ' '.repeat(Math.max(0, token.column - 1)) + '~'.repeat(token.value.length);
|
|
2086
|
+
const lineNumWidth = String(token.line + 1).length;
|
|
2087
|
+
const num = (n) => String(n).padStart(lineNumWidth, ' ');
|
|
2088
|
+
let context = '';
|
|
2089
|
+
if (lineBefore.trim() !== '') {
|
|
2090
|
+
context += `${num(token.line - 1)} | ${lineBefore}\n`;
|
|
2091
|
+
}
|
|
2092
|
+
context += `${num(token.line)} | ${current}\n`;
|
|
2093
|
+
context += `${' '.repeat(lineNumWidth)} | ${wave}\n`;
|
|
2094
|
+
if (linesAfter.trim() !== '') {
|
|
2095
|
+
context += `${num(token.line + 1)} | ${linesAfter}`;
|
|
2096
|
+
}
|
|
2097
|
+
let finalDetails = details;
|
|
2098
|
+
if (expected && expected?.length > 0) {
|
|
2099
|
+
const actual = token.value;
|
|
2100
|
+
const suggestion = this.suggest(actual, expected);
|
|
2101
|
+
const detailsMsg = `${details}\nDetails: Unexpected value token \`${actual}\``;
|
|
2102
|
+
finalDetails = suggestion ? `${detailsMsg} Did you mean \`${suggestion}\`?` : detailsMsg;
|
|
2103
|
+
}
|
|
2104
|
+
throw new AbilityDSLSyntaxError(token.line, token.column, context + '\n', finalDetails);
|
|
2105
|
+
}
|
|
2106
|
+
suggest(actual, expectedTypes) {
|
|
2107
|
+
const candidates = [];
|
|
2108
|
+
for (const type of expectedTypes) {
|
|
2109
|
+
candidates.push(type);
|
|
2110
|
+
}
|
|
2111
|
+
const uniqueCandidates = [...new Set(candidates)];
|
|
2112
|
+
let best = null;
|
|
2113
|
+
let bestDist = 3;
|
|
2114
|
+
for (const candidate of uniqueCandidates) {
|
|
2115
|
+
const d = this.levenshteinDistance(actual.toLowerCase(), candidate.toLowerCase());
|
|
2116
|
+
if (d < bestDist) {
|
|
2117
|
+
bestDist = d;
|
|
2118
|
+
best = candidate;
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
return best;
|
|
2122
|
+
}
|
|
2123
|
+
levenshteinDistance(a, b) {
|
|
2124
|
+
const matrix = Array.from({ length: b.length + 1 }, () => Array.from({ length: a.length + 1 }, () => 0));
|
|
2125
|
+
for (let i = 0; i <= a.length; i++)
|
|
2126
|
+
matrix[0][i] = i;
|
|
2127
|
+
for (let j = 0; j <= b.length; j++)
|
|
2128
|
+
matrix[j][0] = j;
|
|
2129
|
+
for (let j = 1; j <= b.length; j++) {
|
|
2130
|
+
for (let i = 1; i <= a.length; i++) {
|
|
2131
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
2132
|
+
matrix[j][i] = Math.min(matrix[j][i - 1] + 1, matrix[j - 1][i] + 1, matrix[j - 1][i - 1] + cost);
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
return matrix[b.length][a.length];
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
class AbilityDSLAnnotations {
|
|
2140
|
+
store = {
|
|
2141
|
+
id: undefined,
|
|
2142
|
+
name: undefined,
|
|
2143
|
+
priority: undefined,
|
|
2144
|
+
description: undefined,
|
|
2145
|
+
disabled: undefined,
|
|
2146
|
+
tags: undefined,
|
|
2147
|
+
};
|
|
2148
|
+
get(key) {
|
|
2149
|
+
return this.store[key] ?? null;
|
|
2150
|
+
}
|
|
2151
|
+
set(key, value, token) {
|
|
2152
|
+
if (value === null) {
|
|
2153
|
+
this.store[key] = undefined;
|
|
2154
|
+
}
|
|
2155
|
+
else {
|
|
2156
|
+
this.store[key] = {
|
|
2157
|
+
key,
|
|
2158
|
+
value,
|
|
2159
|
+
token,
|
|
2160
|
+
};
|
|
2161
|
+
}
|
|
2162
|
+
return this;
|
|
1490
2163
|
}
|
|
1491
|
-
|
|
1492
|
-
|
|
2164
|
+
clear() {
|
|
2165
|
+
for (const key of Object.keys(this.store)) {
|
|
2166
|
+
this.store[key] = undefined;
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
clone() {
|
|
2170
|
+
const cloned = new AbilityDSLAnnotations();
|
|
2171
|
+
for (const key of Object.keys(this.store)) {
|
|
2172
|
+
const entry = this.store[key];
|
|
2173
|
+
cloned.store[key] = entry
|
|
2174
|
+
? { ...entry }
|
|
2175
|
+
: undefined;
|
|
2176
|
+
}
|
|
2177
|
+
return cloned;
|
|
2178
|
+
}
|
|
2179
|
+
// convenience getters
|
|
2180
|
+
get id() { return this.get('id'); }
|
|
2181
|
+
get name() { return this.get('name'); }
|
|
2182
|
+
get description() { return this.get('description'); }
|
|
2183
|
+
get priority() { return this.get('priority'); }
|
|
2184
|
+
get disabled() { return this.get('disabled'); }
|
|
2185
|
+
get tags() { return this.get('tags'); }
|
|
2186
|
+
// convenience setters
|
|
2187
|
+
setID(v, t) { return this.set('id', v, t); }
|
|
2188
|
+
setName(v, t) { return this.set('name', v, t); }
|
|
2189
|
+
setDescription(v, t) { return this.set('description', v, t); }
|
|
2190
|
+
setPriority(v, t) { return this.set('priority', v, t); }
|
|
2191
|
+
setDisabled(v, t) { return this.set('disabled', v, t); }
|
|
2192
|
+
setTags(v, t) { return this.set('tags', v, t); }
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
const AnnotationAllowed = {
|
|
2196
|
+
policy: new Set(["id", "name", "description", "priority", "disabled", "tags"]),
|
|
2197
|
+
ruleSet: new Set(["id", "name", "description", "disabled"]),
|
|
2198
|
+
rule: new Set(["id", "name", "disabled"]),
|
|
2199
|
+
alias: new Set(["name", "disabled"]),
|
|
2200
|
+
};
|
|
2201
|
+
|
|
2202
|
+
class AbilityDSLAliases {
|
|
2203
|
+
store = new Map();
|
|
2204
|
+
get(alias) {
|
|
2205
|
+
return this.store.get(alias) || null;
|
|
1493
2206
|
}
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
const RED = useColor ? '\x1b[31m' : '';
|
|
1498
|
-
const ORANGE = useColor ? '\x1b[33;1m' : '';
|
|
1499
|
-
const GRAY = useColor ? '\x1b[90m' : '';
|
|
1500
|
-
const RESET = useColor ? '\x1b[0m' : '';
|
|
1501
|
-
const lines = this.context.split('\n');
|
|
1502
|
-
// Find line with ^
|
|
1503
|
-
const pointerIndex = lines.findIndex(l => l.includes('^') || l.includes('~'));
|
|
1504
|
-
const commentIndex = lines.findIndex(l => l.trim().includes('#'));
|
|
1505
|
-
const formattedLines = lines.map((line, idx) => {
|
|
1506
|
-
if (idx === pointerIndex - 1) {
|
|
1507
|
-
// Error line
|
|
1508
|
-
return `${BOLD}${ORANGE}${line}${RESET}`;
|
|
1509
|
-
}
|
|
1510
|
-
if (idx === pointerIndex) {
|
|
1511
|
-
// Error with ~~~~~
|
|
1512
|
-
return `${RED}${line}${RESET}`;
|
|
1513
|
-
}
|
|
1514
|
-
// Comments # ...
|
|
1515
|
-
if (idx === commentIndex) {
|
|
1516
|
-
return `${GRAY}${line}${RESET}`;
|
|
1517
|
-
}
|
|
1518
|
-
return line;
|
|
1519
|
-
});
|
|
1520
|
-
const contextBlock = formattedLines.join('\n');
|
|
1521
|
-
return `${BOLD}${RED}${this.name}: ${this.details}${RESET}\n\n` + contextBlock;
|
|
2207
|
+
set(alias, rule) {
|
|
2208
|
+
this.store.set(alias, rule);
|
|
2209
|
+
return this;
|
|
1522
2210
|
}
|
|
1523
|
-
|
|
1524
|
-
return this.
|
|
2211
|
+
has(alias) {
|
|
2212
|
+
return this.store.has(alias);
|
|
1525
2213
|
}
|
|
1526
2214
|
}
|
|
1527
2215
|
|
|
@@ -1543,12 +2231,9 @@ class AbilityDSLSyntaxError extends Error {
|
|
|
1543
2231
|
*/
|
|
1544
2232
|
class AbilityDSLParser {
|
|
1545
2233
|
dsl;
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
name: null,
|
|
1550
|
-
description: null,
|
|
1551
|
-
};
|
|
2234
|
+
stream;
|
|
2235
|
+
annBuffer = new AbilityDSLAnnotations();
|
|
2236
|
+
aliasBuffer = new AbilityDSLAliases();
|
|
1552
2237
|
constructor(dsl) {
|
|
1553
2238
|
this.dsl = dsl;
|
|
1554
2239
|
}
|
|
@@ -1557,17 +2242,19 @@ class AbilityDSLParser {
|
|
|
1557
2242
|
* @returns Array of AbilityPolicy instances.
|
|
1558
2243
|
*/
|
|
1559
2244
|
parse() {
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
2245
|
+
this.annBuffer.clear();
|
|
2246
|
+
// 1. Лексер → токены
|
|
2247
|
+
const tokens = new AbilityDSLLexer(this.dsl).tokenize();
|
|
2248
|
+
// 2. Создаём TokenStream
|
|
2249
|
+
this.stream = new AbilityDSLTokenStream(tokens, this.dsl);
|
|
1563
2250
|
const policies = [];
|
|
1564
|
-
|
|
1565
|
-
while (!this.isAtEnd()) {
|
|
2251
|
+
while (!this.stream.eof()) {
|
|
1566
2252
|
this.consumeLeadingComments();
|
|
1567
|
-
|
|
2253
|
+
this.consumeLeadingAnnotations();
|
|
2254
|
+
this.consumeLeadingAliases();
|
|
1568
2255
|
if (!this.isStartOfPolicy()) {
|
|
1569
|
-
const token = this.peek();
|
|
1570
|
-
this.syntaxError(`Expected policy, got ${token.
|
|
2256
|
+
const token = this.stream.peek();
|
|
2257
|
+
this.stream.syntaxError(`Expected policy, got ${token.type}.`, token, [TokenTypes.EFFECT]);
|
|
1571
2258
|
}
|
|
1572
2259
|
policies.push(this.parsePolicy());
|
|
1573
2260
|
}
|
|
@@ -1584,31 +2271,37 @@ class AbilityDSLParser {
|
|
|
1584
2271
|
*/
|
|
1585
2272
|
parsePolicy() {
|
|
1586
2273
|
this.consumeLeadingComments();
|
|
1587
|
-
|
|
2274
|
+
this.consumeLeadingAnnotations();
|
|
2275
|
+
this.consumeLeadingAliases();
|
|
2276
|
+
const annotations = this.takeAnnotations('policy');
|
|
1588
2277
|
// Effect: "permit" or "deny"
|
|
1589
|
-
const effectToken = this.
|
|
2278
|
+
const effectToken = this.stream.expect(TokenTypes.EFFECT, 'Expected effect');
|
|
1590
2279
|
const effect = effectToken.value;
|
|
1591
2280
|
// Permission: e.g. "order.update"
|
|
1592
|
-
const permissionToken = this.
|
|
2281
|
+
const permissionToken = this.stream.expect(TokenTypes.PERMISSION, 'Expected permission');
|
|
1593
2282
|
const permission = permissionToken.value;
|
|
1594
2283
|
if (!permission.startsWith('permission.')) {
|
|
1595
|
-
return this.syntaxError(`Unexpected token. The permission key, must be starts with prefix \`permission.\`, but got \`${permission}\`.\nDid you mean \`permission.${permission}\`?`, permissionToken);
|
|
2284
|
+
return this.stream.syntaxError(`Unexpected token. The permission key, must be starts with prefix \`permission.\`, but got \`${permission}\`.\nDid you mean \`permission.${permission}\`?`, permissionToken);
|
|
1596
2285
|
}
|
|
1597
2286
|
// "if" keyword
|
|
1598
|
-
this.
|
|
2287
|
+
this.stream.expect(TokenTypes.IF, 'Expected "if"');
|
|
1599
2288
|
// Group selector: "all" or "any" – determines how the top‑level rule sets are combined.
|
|
1600
|
-
const compareToken = this.
|
|
1601
|
-
const compareMethod = compareToken.
|
|
2289
|
+
const compareToken = this.stream.expectOneOf([TokenTypes.ALL, TokenTypes.ANY], 'Expected "all" or "any"');
|
|
2290
|
+
const compareMethod = compareToken.type === TokenTypes.ALL ? AbilityCompare.and : AbilityCompare.or;
|
|
1602
2291
|
// Colon after the group keyword
|
|
1603
|
-
this.
|
|
2292
|
+
this.stream.expect(TokenTypes.COLON, 'Expected ":"');
|
|
1604
2293
|
// Parse the list of rule sets (each "all of:" or "any of:" block)
|
|
1605
2294
|
const ruleSets = this.parseRuleSets(compareMethod);
|
|
1606
2295
|
// Construct the policy instance.
|
|
1607
2296
|
return new AbilityPolicy({
|
|
1608
|
-
id:
|
|
1609
|
-
name:
|
|
2297
|
+
id: annotations.id?.value || null,
|
|
2298
|
+
name: annotations.name?.value || null,
|
|
2299
|
+
description: annotations.description?.value || null,
|
|
2300
|
+
priority: annotations.priority?.value || null,
|
|
1610
2301
|
permission: permission.replace(/^permission\./, ''),
|
|
1611
2302
|
effect: effect === 'permit' ? AbilityPolicyEffect.permit : AbilityPolicyEffect.deny,
|
|
2303
|
+
disabled: annotations.disabled?.value ?? undefined,
|
|
2304
|
+
tags: annotations.tags?.value ?? undefined,
|
|
1612
2305
|
compareMethod,
|
|
1613
2306
|
}).addRuleSets(ruleSets);
|
|
1614
2307
|
}
|
|
@@ -1620,32 +2313,41 @@ class AbilityDSLParser {
|
|
|
1620
2313
|
*/
|
|
1621
2314
|
parseRuleSets(policyCompareMethod) {
|
|
1622
2315
|
const sets = [];
|
|
1623
|
-
while (!this.
|
|
2316
|
+
while (!this.stream.eof() && !this.isStartOfPolicy()) {
|
|
1624
2317
|
this.consumeLeadingComments();
|
|
2318
|
+
this.consumeLeadingAnnotations();
|
|
2319
|
+
// Если начинается новая except группа — парсим её
|
|
2320
|
+
if (this.isStartOfExcept()) {
|
|
2321
|
+
sets.push(this.parseExceptGroup(policyCompareMethod));
|
|
2322
|
+
continue;
|
|
2323
|
+
}
|
|
1625
2324
|
// Если начинается новая группа — парсим её
|
|
1626
2325
|
if (this.isStartOfGroup()) {
|
|
1627
2326
|
sets.push(this.parseGroup());
|
|
1628
2327
|
continue;
|
|
1629
2328
|
}
|
|
1630
|
-
|
|
1631
|
-
const meta = this.takeAnnotations();
|
|
2329
|
+
const annotation = this.takeAnnotations('ruleSet');
|
|
1632
2330
|
const group = new AbilityRuleSet({
|
|
2331
|
+
id: annotation.id?.value || null,
|
|
1633
2332
|
compareMethod: policyCompareMethod,
|
|
1634
|
-
name:
|
|
2333
|
+
name: annotation.name?.value ?? null,
|
|
2334
|
+
description: annotation.description?.value || null,
|
|
2335
|
+
disabled: annotation.disabled?.value ?? undefined,
|
|
1635
2336
|
});
|
|
1636
2337
|
// Читаем правила implicit-группы
|
|
1637
|
-
while (!this.
|
|
2338
|
+
while (!this.stream.eof()) {
|
|
1638
2339
|
this.consumeLeadingComments();
|
|
1639
|
-
|
|
2340
|
+
this.consumeLeadingAnnotations();
|
|
2341
|
+
if (this.isStartOfGroup() || this.isStartOfPolicy() || this.isStartOfExcept()) {
|
|
1640
2342
|
break;
|
|
1641
2343
|
}
|
|
1642
|
-
if (this.check(
|
|
1643
|
-
this.check(
|
|
1644
|
-
this.check(
|
|
2344
|
+
if (this.stream.check(TokenTypes.IDENTIFIER) ||
|
|
2345
|
+
this.stream.check(TokenTypes.ALWAYS) ||
|
|
2346
|
+
this.stream.check(TokenTypes.NEVER)) {
|
|
1645
2347
|
group.addRule(this.parseRule());
|
|
1646
2348
|
}
|
|
1647
2349
|
else {
|
|
1648
|
-
this.syntaxError(`Unexpected token in implicit group: ${this.peek().
|
|
2350
|
+
this.stream.syntaxError(`Unexpected token in implicit group: ${this.stream.peek().type}`, this.stream.peek());
|
|
1649
2351
|
}
|
|
1650
2352
|
}
|
|
1651
2353
|
sets.push(group);
|
|
@@ -1657,24 +2359,83 @@ class AbilityDSLParser {
|
|
|
1657
2359
|
*/
|
|
1658
2360
|
parseGroup() {
|
|
1659
2361
|
this.consumeLeadingComments();
|
|
1660
|
-
|
|
1661
|
-
const
|
|
1662
|
-
const
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
2362
|
+
this.consumeLeadingAnnotations();
|
|
2363
|
+
const annotations = this.takeAnnotations('ruleSet');
|
|
2364
|
+
const compareToken = this.stream.expectOneOf([TokenTypes.ALL, TokenTypes.ANY, TokenTypes.ALWAYS, TokenTypes.NEVER], 'Expected "all" or "any" or "always" or "never"');
|
|
2365
|
+
const compareMethod = compareToken.type === TokenTypes.ALL ? AbilityCompare.and : AbilityCompare.or;
|
|
2366
|
+
if (this.stream.check(TokenTypes.OF)) {
|
|
2367
|
+
this.stream.next();
|
|
2368
|
+
}
|
|
2369
|
+
this.stream.expect(TokenTypes.COLON, 'Expected ":"');
|
|
2370
|
+
const group = new AbilityRuleSet({
|
|
2371
|
+
id: annotations.id?.value || null,
|
|
2372
|
+
compareMethod,
|
|
2373
|
+
name: annotations.name?.value || null,
|
|
2374
|
+
description: annotations.description?.value || null,
|
|
2375
|
+
disabled: annotations.disabled?.value ?? undefined,
|
|
2376
|
+
});
|
|
2377
|
+
while (!this.stream.eof()) {
|
|
1669
2378
|
this.consumeLeadingComments();
|
|
2379
|
+
this.consumeLeadingAnnotations();
|
|
2380
|
+
if (this.isStartOfExcept()) {
|
|
2381
|
+
break;
|
|
2382
|
+
}
|
|
1670
2383
|
if (this.isStartOfGroup() || this.isStartOfPolicy()) {
|
|
1671
2384
|
break;
|
|
1672
2385
|
}
|
|
1673
|
-
if (this.check(
|
|
2386
|
+
if (this.stream.check(TokenTypes.IDENTIFIER)) {
|
|
2387
|
+
group.addRule(this.parseRule());
|
|
2388
|
+
}
|
|
2389
|
+
else {
|
|
2390
|
+
this.stream.syntaxError(`Unexpected token in group: ${this.stream.peek().type}`, this.stream.peek());
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
return group;
|
|
2394
|
+
}
|
|
2395
|
+
// -------------------------------------------------------------------------
|
|
2396
|
+
// #region Except RuleSet parsing
|
|
2397
|
+
// -------------------------------------------------------------------------
|
|
2398
|
+
parseExceptGroup(policyCompareMethod) {
|
|
2399
|
+
this.consumeLeadingComments();
|
|
2400
|
+
this.consumeLeadingAnnotations();
|
|
2401
|
+
const annotations = this.takeAnnotations('ruleSet');
|
|
2402
|
+
// consume "except"
|
|
2403
|
+
this.stream.expect(TokenTypes.EXCEPT, 'Expected "except"');
|
|
2404
|
+
let compareMethod = policyCompareMethod;
|
|
2405
|
+
// optional: "all" / "any"
|
|
2406
|
+
if (this.stream.check(TokenTypes.ALL) || this.stream.check(TokenTypes.ANY)) {
|
|
2407
|
+
const compareToken = this.stream.next();
|
|
2408
|
+
compareMethod = compareToken.type === TokenTypes.ALL ? AbilityCompare.and : AbilityCompare.or;
|
|
2409
|
+
if (this.stream.check(TokenTypes.OF)) {
|
|
2410
|
+
this.stream.next();
|
|
2411
|
+
}
|
|
2412
|
+
this.stream.expect(TokenTypes.COLON, 'Expected ":" after except group');
|
|
2413
|
+
}
|
|
2414
|
+
else {
|
|
2415
|
+
// implicit except group — no "all/any of:"
|
|
2416
|
+
// but still must end with colon
|
|
2417
|
+
this.stream.expect(TokenTypes.COLON, 'Expected ":" after "except"');
|
|
2418
|
+
}
|
|
2419
|
+
const group = new AbilityRuleSet({
|
|
2420
|
+
id: annotations.id?.value || null,
|
|
2421
|
+
compareMethod,
|
|
2422
|
+
name: annotations.name?.value || null,
|
|
2423
|
+
description: annotations.description?.value || null,
|
|
2424
|
+
isExcept: true,
|
|
2425
|
+
disabled: annotations.disabled?.value ?? undefined,
|
|
2426
|
+
});
|
|
2427
|
+
// read rules
|
|
2428
|
+
while (!this.stream.eof()) {
|
|
2429
|
+
this.consumeLeadingComments();
|
|
2430
|
+
this.consumeLeadingAnnotations();
|
|
2431
|
+
if (this.isStartOfGroup() || this.isStartOfPolicy() || this.isStartOfExcept()) {
|
|
2432
|
+
break;
|
|
2433
|
+
}
|
|
2434
|
+
if (this.stream.check(TokenTypes.IDENTIFIER)) {
|
|
1674
2435
|
group.addRule(this.parseRule());
|
|
1675
2436
|
}
|
|
1676
2437
|
else {
|
|
1677
|
-
this.syntaxError(`Unexpected token in group: ${this.peek().
|
|
2438
|
+
this.stream.syntaxError(`Unexpected token in except group: ${this.stream.peek().type}`, this.stream.peek());
|
|
1678
2439
|
}
|
|
1679
2440
|
}
|
|
1680
2441
|
return group;
|
|
@@ -1687,57 +2448,54 @@ class AbilityDSLParser {
|
|
|
1687
2448
|
*/
|
|
1688
2449
|
parseRule() {
|
|
1689
2450
|
this.consumeLeadingComments();
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
//
|
|
1697
|
-
// // subject: '',
|
|
1698
|
-
// // resource,
|
|
1699
|
-
// // condition,
|
|
1700
|
-
// // name: meta.name,
|
|
1701
|
-
// // });
|
|
1702
|
-
// }
|
|
1703
|
-
const isNeverAlways = this.check(AbilityDSLToken.ALWAYS) || this.check(AbilityDSLToken.NEVER);
|
|
1704
|
-
if (!isNeverAlways && !this.check(AbilityDSLToken.IDENTIFIER)) {
|
|
1705
|
-
this.syntaxError(`Expected identifier, but got ${this.peek().code}`, this.peek());
|
|
1706
|
-
}
|
|
1707
|
-
// Subject (e.g., "user.roles")
|
|
2451
|
+
this.consumeLeadingAnnotations();
|
|
2452
|
+
const annotations = this.takeAnnotations('rule');
|
|
2453
|
+
const isNeverAlways = this.stream.check(TokenTypes.ALWAYS) || this.stream.check(TokenTypes.NEVER);
|
|
2454
|
+
if (!isNeverAlways && !this.stream.check(TokenTypes.IDENTIFIER)) {
|
|
2455
|
+
this.stream.syntaxError(`Expected identifier, but got ${this.stream.peek().type}`, this.stream.peek());
|
|
2456
|
+
}
|
|
2457
|
+
// subject
|
|
1708
2458
|
const subject = isNeverAlways
|
|
1709
2459
|
? ''
|
|
1710
|
-
: this.
|
|
1711
|
-
//
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
let beforePos = this.pos;
|
|
1715
|
-
// Special operators that don't consume a value token.
|
|
1716
|
-
if (operator === AbilityDSLToken.EQ_NULL ||
|
|
1717
|
-
operator === AbilityDSLToken.NOT_EQ_NULL ||
|
|
1718
|
-
operator === AbilityDSLToken.NULL ||
|
|
1719
|
-
operator === AbilityDSLToken.ALWAYS ||
|
|
1720
|
-
operator === AbilityDSLToken.NEVER) {
|
|
1721
|
-
resource = null;
|
|
2460
|
+
: this.stream.expect(TokenTypes.IDENTIFIER, 'Expected field').value;
|
|
2461
|
+
// check alias
|
|
2462
|
+
if (this.aliasBuffer.has(subject)) {
|
|
2463
|
+
return this.aliasBuffer.get(subject);
|
|
1722
2464
|
}
|
|
1723
|
-
|
|
1724
|
-
|
|
2465
|
+
// operator
|
|
2466
|
+
const { condition, operator } = this.parseConditionOperator();
|
|
2467
|
+
// value
|
|
2468
|
+
let resource = null;
|
|
2469
|
+
let valueToken = null;
|
|
2470
|
+
const operatorConsumesValue = operator !== TokenTypes.EQ_NULL &&
|
|
2471
|
+
operator !== TokenTypes.NOT_EQ_NULL &&
|
|
2472
|
+
operator !== TokenTypes.NULL &&
|
|
2473
|
+
operator !== TokenTypes.ALWAYS &&
|
|
2474
|
+
operator !== TokenTypes.NEVER;
|
|
2475
|
+
if (operatorConsumesValue) {
|
|
2476
|
+
this.stream.mark();
|
|
1725
2477
|
resource = this.parseValue();
|
|
2478
|
+
valueToken = this.stream.lookPrev();
|
|
2479
|
+
this.stream.commit();
|
|
1726
2480
|
}
|
|
1727
|
-
// Checking that there are no extra tokens after the value
|
|
1728
|
-
// (skip comments)
|
|
1729
2481
|
this.consumeLeadingComments();
|
|
1730
|
-
|
|
2482
|
+
this.consumeLeadingAnnotations();
|
|
2483
|
+
this.consumeLeadingAliases();
|
|
2484
|
+
// validation: identifier without dot → error
|
|
1731
2485
|
if (typeof resource === 'string' &&
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
2486
|
+
valueToken &&
|
|
2487
|
+
valueToken.type === TokenTypes.IDENTIFIER &&
|
|
2488
|
+
!valueToken.value.includes('.')) {
|
|
2489
|
+
this.stream.syntaxError(`Expected comparison operator or value, got \`${resource}\``, valueToken, [TokenTypes.KEYWORD]);
|
|
1735
2490
|
}
|
|
1736
2491
|
return new AbilityRule({
|
|
2492
|
+
id: annotations.id?.value || null,
|
|
1737
2493
|
subject,
|
|
1738
2494
|
resource,
|
|
1739
2495
|
condition,
|
|
1740
|
-
name:
|
|
2496
|
+
name: annotations.name?.value || null,
|
|
2497
|
+
description: annotations.description?.value || null,
|
|
2498
|
+
disabled: annotations.disabled?.value ?? undefined,
|
|
1741
2499
|
});
|
|
1742
2500
|
}
|
|
1743
2501
|
// -------------------------------------------------------------------------
|
|
@@ -1748,200 +2506,241 @@ class AbilityDSLParser {
|
|
|
1748
2506
|
* Returns both the resulting AbilityCondition and the token type that was consumed.
|
|
1749
2507
|
*/
|
|
1750
2508
|
parseConditionOperator() {
|
|
1751
|
-
const savedPos = this.pos;
|
|
1752
2509
|
// "always"
|
|
2510
|
+
this.stream.mark();
|
|
1753
2511
|
if (this.matchWord('always')) {
|
|
1754
|
-
|
|
2512
|
+
this.stream.commit();
|
|
2513
|
+
return { condition: AbilityCondition.always, operator: TokenTypes.ALWAYS };
|
|
1755
2514
|
}
|
|
1756
|
-
this.
|
|
2515
|
+
this.stream.reset();
|
|
1757
2516
|
// "never"
|
|
2517
|
+
this.stream.mark();
|
|
1758
2518
|
if (this.matchWord('never')) {
|
|
1759
|
-
|
|
2519
|
+
this.stream.commit();
|
|
2520
|
+
return { condition: AbilityCondition.never, operator: TokenTypes.NEVER };
|
|
1760
2521
|
}
|
|
1761
|
-
this.
|
|
2522
|
+
this.stream.reset();
|
|
1762
2523
|
// "length equals"
|
|
2524
|
+
this.stream.mark();
|
|
1763
2525
|
if (this.matchWord('length') && this.matchWord('equals')) {
|
|
1764
|
-
|
|
2526
|
+
this.stream.commit();
|
|
2527
|
+
return { condition: AbilityCondition.length_equals, operator: TokenTypes.LEN_EQ };
|
|
1765
2528
|
}
|
|
1766
|
-
this.
|
|
2529
|
+
this.stream.reset();
|
|
1767
2530
|
// "length ="
|
|
2531
|
+
this.stream.mark();
|
|
1768
2532
|
if (this.matchWord('length') && this.matchSymbol('=')) {
|
|
1769
|
-
|
|
2533
|
+
this.stream.commit();
|
|
2534
|
+
return { condition: AbilityCondition.length_equals, operator: TokenTypes.LEN_EQ };
|
|
1770
2535
|
}
|
|
1771
|
-
this.
|
|
2536
|
+
this.stream.reset();
|
|
1772
2537
|
// "length greater than"
|
|
2538
|
+
this.stream.mark();
|
|
1773
2539
|
if (this.matchWord('length') && this.matchWord('greater') && this.matchWord('than')) {
|
|
1774
|
-
|
|
2540
|
+
this.stream.commit();
|
|
2541
|
+
return { condition: AbilityCondition.length_greater_than, operator: TokenTypes.LEN_GT };
|
|
1775
2542
|
}
|
|
1776
|
-
this.
|
|
2543
|
+
this.stream.reset();
|
|
1777
2544
|
// "length >"
|
|
2545
|
+
this.stream.mark();
|
|
1778
2546
|
if (this.matchWord('length') && this.matchSymbol('>')) {
|
|
1779
|
-
|
|
2547
|
+
this.stream.commit();
|
|
2548
|
+
return { condition: AbilityCondition.length_greater_than, operator: TokenTypes.LEN_GT };
|
|
1780
2549
|
}
|
|
1781
|
-
this.
|
|
2550
|
+
this.stream.reset();
|
|
1782
2551
|
// "length less than"
|
|
2552
|
+
this.stream.mark();
|
|
1783
2553
|
if (this.matchWord('length') && this.matchWord('less') && this.matchWord('than')) {
|
|
1784
|
-
|
|
2554
|
+
this.stream.commit();
|
|
2555
|
+
return { condition: AbilityCondition.length_less_than, operator: TokenTypes.LEN_LT };
|
|
1785
2556
|
}
|
|
1786
|
-
this.
|
|
2557
|
+
this.stream.reset();
|
|
1787
2558
|
// "length <"
|
|
2559
|
+
this.stream.mark();
|
|
1788
2560
|
if (this.matchWord('length') && this.matchSymbol('<')) {
|
|
1789
|
-
|
|
2561
|
+
this.stream.commit();
|
|
2562
|
+
return { condition: AbilityCondition.length_less_than, operator: TokenTypes.LEN_LT };
|
|
1790
2563
|
}
|
|
1791
|
-
this.
|
|
2564
|
+
this.stream.reset();
|
|
1792
2565
|
// "greater than or equal"
|
|
2566
|
+
this.stream.mark();
|
|
1793
2567
|
if (this.matchWord('greater') &&
|
|
1794
2568
|
this.matchWord('than') &&
|
|
1795
2569
|
this.matchWord('or') &&
|
|
1796
2570
|
this.matchWord('equal')) {
|
|
1797
|
-
|
|
2571
|
+
this.stream.commit();
|
|
2572
|
+
return { condition: AbilityCondition.greater_or_equal, operator: TokenTypes.GTE };
|
|
1798
2573
|
}
|
|
1799
|
-
this.
|
|
2574
|
+
this.stream.reset();
|
|
1800
2575
|
// greater than
|
|
2576
|
+
this.stream.mark();
|
|
1801
2577
|
if (this.matchWord('greater') && this.matchWord('than')) {
|
|
1802
|
-
|
|
2578
|
+
this.stream.commit();
|
|
2579
|
+
return { condition: AbilityCondition.greater_than, operator: TokenTypes.GT };
|
|
1803
2580
|
}
|
|
1804
|
-
this.
|
|
2581
|
+
this.stream.reset();
|
|
1805
2582
|
// less than or equal
|
|
2583
|
+
this.stream.mark();
|
|
1806
2584
|
if (this.matchWord('less') &&
|
|
1807
2585
|
this.matchWord('than') &&
|
|
1808
2586
|
this.matchWord('or') &&
|
|
1809
2587
|
this.matchWord('equal')) {
|
|
1810
|
-
|
|
2588
|
+
this.stream.commit();
|
|
2589
|
+
return { condition: AbilityCondition.less_or_equal, operator: TokenTypes.LTE };
|
|
1811
2590
|
}
|
|
1812
|
-
this.
|
|
2591
|
+
this.stream.reset();
|
|
1813
2592
|
// less than
|
|
1814
2593
|
if (this.matchWord('less') && this.matchWord('than')) {
|
|
1815
|
-
return { condition: AbilityCondition.less_than, operator:
|
|
2594
|
+
return { condition: AbilityCondition.less_than, operator: TokenTypes.LT };
|
|
1816
2595
|
}
|
|
1817
|
-
this.
|
|
2596
|
+
this.stream.reset();
|
|
1818
2597
|
// "not contains"
|
|
2598
|
+
this.stream.mark();
|
|
1819
2599
|
if (this.matchWord('not') && this.matchWord('contains')) {
|
|
2600
|
+
this.stream.commit();
|
|
1820
2601
|
return {
|
|
1821
2602
|
condition: AbilityCondition.not_contains,
|
|
1822
|
-
operator:
|
|
2603
|
+
operator: TokenTypes.NOT_CONTAINS,
|
|
1823
2604
|
};
|
|
1824
2605
|
}
|
|
1825
|
-
this.
|
|
2606
|
+
this.stream.reset();
|
|
1826
2607
|
// "not includes"
|
|
2608
|
+
this.stream.mark();
|
|
1827
2609
|
if (this.matchWord('not') && this.matchWord('includes')) {
|
|
2610
|
+
this.stream.commit();
|
|
1828
2611
|
return {
|
|
1829
2612
|
condition: AbilityCondition.not_contains,
|
|
1830
|
-
operator:
|
|
2613
|
+
operator: TokenTypes.NOT_CONTAINS,
|
|
1831
2614
|
};
|
|
1832
2615
|
}
|
|
1833
|
-
this.
|
|
2616
|
+
this.stream.reset();
|
|
1834
2617
|
// "not includes"
|
|
2618
|
+
this.stream.mark();
|
|
1835
2619
|
if (this.matchWord('not') && this.matchWord('has')) {
|
|
2620
|
+
this.stream.commit();
|
|
1836
2621
|
return {
|
|
1837
2622
|
condition: AbilityCondition.not_contains,
|
|
1838
|
-
operator:
|
|
2623
|
+
operator: TokenTypes.NOT_CONTAINS,
|
|
1839
2624
|
};
|
|
1840
2625
|
}
|
|
1841
|
-
this.
|
|
2626
|
+
this.stream.reset();
|
|
1842
2627
|
// "is equals"
|
|
2628
|
+
this.stream.mark();
|
|
1843
2629
|
if (this.matchWord('is') && this.matchWord('equals')) {
|
|
1844
|
-
|
|
2630
|
+
this.stream.commit();
|
|
2631
|
+
return { condition: AbilityCondition.equals, operator: TokenTypes.EQ };
|
|
1845
2632
|
}
|
|
1846
|
-
this.
|
|
2633
|
+
this.stream.reset();
|
|
1847
2634
|
// not equal
|
|
2635
|
+
this.stream.mark();
|
|
1848
2636
|
if (this.matchWord('not') && this.matchWord('equals')) {
|
|
1849
|
-
|
|
2637
|
+
this.stream.commit();
|
|
2638
|
+
return { condition: AbilityCondition.not_equals, operator: TokenTypes.NOT_EQ };
|
|
1850
2639
|
}
|
|
1851
|
-
this.
|
|
2640
|
+
this.stream.reset();
|
|
1852
2641
|
// is not equals
|
|
2642
|
+
this.stream.mark();
|
|
1853
2643
|
if (this.matchWord('is') && this.matchWord('not') && this.matchWord('equals')) {
|
|
1854
|
-
|
|
2644
|
+
this.stream.commit();
|
|
2645
|
+
return { condition: AbilityCondition.not_equals, operator: TokenTypes.NOT_EQ };
|
|
1855
2646
|
}
|
|
1856
|
-
this.
|
|
2647
|
+
this.stream.reset();
|
|
1857
2648
|
// is in
|
|
2649
|
+
this.stream.mark();
|
|
1858
2650
|
if (this.matchWord('is') && this.matchWord('in')) {
|
|
1859
|
-
|
|
2651
|
+
this.stream.commit();
|
|
2652
|
+
return { condition: AbilityCondition.in, operator: TokenTypes.IN };
|
|
1860
2653
|
}
|
|
1861
|
-
this.
|
|
2654
|
+
this.stream.reset();
|
|
1862
2655
|
// not in
|
|
2656
|
+
this.stream.mark();
|
|
1863
2657
|
if (this.matchWord('not') && this.matchWord('in')) {
|
|
1864
|
-
|
|
2658
|
+
this.stream.commit();
|
|
2659
|
+
return { condition: AbilityCondition.not_in, operator: TokenTypes.NOT_IN };
|
|
1865
2660
|
}
|
|
1866
|
-
this.
|
|
2661
|
+
this.stream.reset();
|
|
1867
2662
|
// is not null
|
|
2663
|
+
this.stream.mark();
|
|
1868
2664
|
if (this.matchWord('is') && this.matchWord('not')) {
|
|
1869
|
-
if (this.check(
|
|
1870
|
-
this.
|
|
2665
|
+
if (this.stream.check(TokenTypes.NULL)) {
|
|
2666
|
+
this.stream.next();
|
|
2667
|
+
this.stream.commit();
|
|
1871
2668
|
return {
|
|
1872
2669
|
condition: AbilityCondition.not_equals,
|
|
1873
|
-
operator:
|
|
2670
|
+
operator: TokenTypes.NOT_EQ_NULL,
|
|
1874
2671
|
};
|
|
1875
2672
|
}
|
|
1876
2673
|
}
|
|
1877
|
-
this.
|
|
2674
|
+
this.stream.reset();
|
|
1878
2675
|
// is null
|
|
2676
|
+
this.stream.mark();
|
|
1879
2677
|
if (this.matchWord('is') && this.matchWord('null')) {
|
|
1880
|
-
if (this.check(
|
|
1881
|
-
this.
|
|
2678
|
+
if (this.stream.check(TokenTypes.NULL)) {
|
|
2679
|
+
this.stream.commit();
|
|
2680
|
+
this.stream.next();
|
|
1882
2681
|
return {
|
|
1883
2682
|
condition: AbilityCondition.equals,
|
|
1884
|
-
operator:
|
|
2683
|
+
operator: TokenTypes.EQ_NULL,
|
|
1885
2684
|
};
|
|
1886
2685
|
}
|
|
1887
2686
|
}
|
|
1888
|
-
this.
|
|
2687
|
+
this.stream.reset();
|
|
1889
2688
|
// Single token (symbol or keyword)
|
|
1890
|
-
const token = this.peek();
|
|
1891
|
-
if (token.
|
|
1892
|
-
token.
|
|
1893
|
-
token.
|
|
1894
|
-
this.syntaxError(`Expected comparison operator, got \`${token.value}\``, token, [
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
2689
|
+
const token = this.stream.peek();
|
|
2690
|
+
if (token.type !== TokenTypes.SYMBOL &&
|
|
2691
|
+
token.type !== TokenTypes.KEYWORD &&
|
|
2692
|
+
token.type !== TokenTypes.NULL) {
|
|
2693
|
+
this.stream.syntaxError(`Expected comparison operator, got \`${token.value}\``, token, [
|
|
2694
|
+
TokenTypes.SYMBOL,
|
|
2695
|
+
TokenTypes.KEYWORD,
|
|
2696
|
+
TokenTypes.NULL,
|
|
1898
2697
|
]);
|
|
1899
2698
|
}
|
|
1900
|
-
this.
|
|
1901
|
-
switch (token.
|
|
1902
|
-
case
|
|
2699
|
+
this.stream.next();
|
|
2700
|
+
switch (token.type) {
|
|
2701
|
+
case TokenTypes.SYMBOL:
|
|
1903
2702
|
if (token.value === '=' || token.value === '==')
|
|
1904
|
-
return { condition: AbilityCondition.equals, operator:
|
|
2703
|
+
return { condition: AbilityCondition.equals, operator: TokenTypes.EQ };
|
|
1905
2704
|
if (token.value === '!=' || token.value === '<>')
|
|
1906
|
-
return { condition: AbilityCondition.not_equals, operator:
|
|
2705
|
+
return { condition: AbilityCondition.not_equals, operator: TokenTypes.NOT_EQ };
|
|
1907
2706
|
if (token.value === '>')
|
|
1908
|
-
return { condition: AbilityCondition.greater_than, operator:
|
|
2707
|
+
return { condition: AbilityCondition.greater_than, operator: TokenTypes.GT };
|
|
1909
2708
|
if (token.value === '<')
|
|
1910
|
-
return { condition: AbilityCondition.less_than, operator:
|
|
2709
|
+
return { condition: AbilityCondition.less_than, operator: TokenTypes.LT };
|
|
1911
2710
|
if (token.value === '>=')
|
|
1912
|
-
return { condition: AbilityCondition.greater_or_equal, operator:
|
|
2711
|
+
return { condition: AbilityCondition.greater_or_equal, operator: TokenTypes.GTE };
|
|
1913
2712
|
if (token.value === '<=')
|
|
1914
|
-
return { condition: AbilityCondition.less_or_equal, operator:
|
|
2713
|
+
return { condition: AbilityCondition.less_or_equal, operator: TokenTypes.LTE };
|
|
1915
2714
|
break;
|
|
1916
|
-
case
|
|
2715
|
+
case TokenTypes.KEYWORD:
|
|
1917
2716
|
if (token.value === 'contains' || token.value === 'includes' || token.value === 'has')
|
|
1918
|
-
return { condition: AbilityCondition.contains, operator:
|
|
2717
|
+
return { condition: AbilityCondition.contains, operator: TokenTypes.CONTAINS };
|
|
1919
2718
|
if (token.value === 'in')
|
|
1920
|
-
return { condition: AbilityCondition.in, operator:
|
|
2719
|
+
return { condition: AbilityCondition.in, operator: TokenTypes.IN };
|
|
1921
2720
|
if (token.value === 'equals')
|
|
1922
|
-
return { condition: AbilityCondition.equals, operator:
|
|
2721
|
+
return { condition: AbilityCondition.equals, operator: TokenTypes.EQ };
|
|
1923
2722
|
if (token.value === 'gte') {
|
|
1924
|
-
return { condition: AbilityCondition.greater_or_equal, operator:
|
|
2723
|
+
return { condition: AbilityCondition.greater_or_equal, operator: TokenTypes.GTE };
|
|
1925
2724
|
}
|
|
1926
2725
|
if (token.value === 'greater' || token.value === 'gt') {
|
|
1927
2726
|
// If we come here, it means "greater" without "than" – treat as '>'
|
|
1928
|
-
return { condition: AbilityCondition.greater_than, operator:
|
|
2727
|
+
return { condition: AbilityCondition.greater_than, operator: TokenTypes.GT };
|
|
1929
2728
|
}
|
|
1930
2729
|
if (token.value === 'less' || token.value === 'lt') {
|
|
1931
|
-
return { condition: AbilityCondition.less_than, operator:
|
|
2730
|
+
return { condition: AbilityCondition.less_than, operator: TokenTypes.LT };
|
|
1932
2731
|
}
|
|
1933
2732
|
if (token.value === 'lte') {
|
|
1934
|
-
return { condition: AbilityCondition.less_or_equal, operator:
|
|
2733
|
+
return { condition: AbilityCondition.less_or_equal, operator: TokenTypes.LTE };
|
|
1935
2734
|
}
|
|
1936
2735
|
if (token.value === 'is') {
|
|
1937
2736
|
// "is" alone -> equals
|
|
1938
|
-
return { condition: AbilityCondition.equals, operator:
|
|
2737
|
+
return { condition: AbilityCondition.equals, operator: TokenTypes.EQ };
|
|
1939
2738
|
}
|
|
1940
2739
|
break;
|
|
1941
2740
|
}
|
|
1942
|
-
return this.syntaxError(`Unexpected operator token \`${token.value}\``, token, [
|
|
1943
|
-
|
|
1944
|
-
|
|
2741
|
+
return this.stream.syntaxError(`Unexpected operator token \`${token.value}\``, token, [
|
|
2742
|
+
TokenTypes.SYMBOL,
|
|
2743
|
+
TokenTypes.KEYWORD,
|
|
1945
2744
|
]);
|
|
1946
2745
|
}
|
|
1947
2746
|
/**
|
|
@@ -1950,26 +2749,26 @@ class AbilityDSLParser {
|
|
|
1950
2749
|
* @returns True if the next token has that value.
|
|
1951
2750
|
*/
|
|
1952
2751
|
matchWord(word) {
|
|
1953
|
-
if (this.
|
|
2752
|
+
if (this.stream.eof()) {
|
|
1954
2753
|
return false;
|
|
1955
2754
|
}
|
|
1956
|
-
const token = this.peek();
|
|
1957
|
-
if ((token.
|
|
1958
|
-
token.
|
|
1959
|
-
token.
|
|
1960
|
-
token.
|
|
2755
|
+
const token = this.stream.peek();
|
|
2756
|
+
if ((token.type === TokenTypes.KEYWORD ||
|
|
2757
|
+
token.type === TokenTypes.IDENTIFIER ||
|
|
2758
|
+
token.type === TokenTypes.ALWAYS ||
|
|
2759
|
+
token.type === TokenTypes.NEVER) &&
|
|
1961
2760
|
token.value === word) {
|
|
1962
|
-
this.
|
|
2761
|
+
this.stream.next();
|
|
1963
2762
|
return true;
|
|
1964
2763
|
}
|
|
1965
2764
|
return false;
|
|
1966
2765
|
}
|
|
1967
2766
|
matchSymbol(symbol) {
|
|
1968
|
-
if (this.
|
|
2767
|
+
if (this.stream.eof())
|
|
1969
2768
|
return false;
|
|
1970
|
-
const token = this.peek();
|
|
1971
|
-
if (token.
|
|
1972
|
-
this.
|
|
2769
|
+
const token = this.stream.peek();
|
|
2770
|
+
if (token.type === TokenTypes.SYMBOL && token.value === symbol) {
|
|
2771
|
+
this.stream.next();
|
|
1973
2772
|
return true;
|
|
1974
2773
|
}
|
|
1975
2774
|
return false;
|
|
@@ -1983,32 +2782,34 @@ class AbilityDSLParser {
|
|
|
1983
2782
|
*/
|
|
1984
2783
|
parseValue() {
|
|
1985
2784
|
// Arrays start with a left bracket
|
|
1986
|
-
if (this.check(
|
|
1987
|
-
this.
|
|
2785
|
+
if (this.stream.check(TokenTypes.LBRACKET)) {
|
|
2786
|
+
this.stream.next();
|
|
1988
2787
|
return this.parseArray();
|
|
1989
2788
|
}
|
|
1990
2789
|
// Ensure we are not about to read a structural token as a value.
|
|
1991
|
-
const token = this.peek();
|
|
1992
|
-
if (token.
|
|
1993
|
-
token.
|
|
1994
|
-
token.
|
|
1995
|
-
this.syntaxError(`Unexpected ${token.
|
|
2790
|
+
const token = this.stream.peek();
|
|
2791
|
+
if (token.type === TokenTypes.ALL ||
|
|
2792
|
+
token.type === TokenTypes.ANY ||
|
|
2793
|
+
token.type === TokenTypes.EFFECT) {
|
|
2794
|
+
this.stream.syntaxError(`Unexpected ${token.type} in value position`, token);
|
|
1996
2795
|
}
|
|
1997
|
-
this.
|
|
2796
|
+
this.stream.next();
|
|
1998
2797
|
// CHECK THIS SWITCH COMPARE
|
|
1999
|
-
switch (token.
|
|
2000
|
-
case
|
|
2798
|
+
switch (token.type) {
|
|
2799
|
+
case TokenTypes.STRING:
|
|
2001
2800
|
return token.value;
|
|
2002
|
-
case
|
|
2801
|
+
case TokenTypes.NUMBER:
|
|
2003
2802
|
return Number(token.value);
|
|
2004
|
-
case
|
|
2803
|
+
case TokenTypes.BOOLEAN:
|
|
2005
2804
|
return token.value === 'true';
|
|
2006
|
-
case
|
|
2805
|
+
case TokenTypes.NULL:
|
|
2007
2806
|
return null;
|
|
2008
|
-
case
|
|
2807
|
+
case TokenTypes.IDENTIFIER:
|
|
2009
2808
|
return token.value;
|
|
2010
2809
|
default: {
|
|
2011
|
-
this.syntaxError(`Unexpected value token "${token.value}"`, token
|
|
2810
|
+
this.stream.syntaxError(`Unexpected value token "${token.value}"`, token, [
|
|
2811
|
+
TokenTypes.KEYWORD,
|
|
2812
|
+
]);
|
|
2012
2813
|
}
|
|
2013
2814
|
}
|
|
2014
2815
|
}
|
|
@@ -2019,11 +2820,11 @@ class AbilityDSLParser {
|
|
|
2019
2820
|
parseArray() {
|
|
2020
2821
|
const arr = [];
|
|
2021
2822
|
// Handle empty array
|
|
2022
|
-
if (this.check(
|
|
2023
|
-
this.
|
|
2823
|
+
if (this.stream.check(TokenTypes.RBRACKET)) {
|
|
2824
|
+
this.stream.next();
|
|
2024
2825
|
return arr;
|
|
2025
2826
|
}
|
|
2026
|
-
while (!this.
|
|
2827
|
+
while (!this.stream.eof() && !this.stream.check(TokenTypes.RBRACKET)) {
|
|
2027
2828
|
const value = this.parseValue();
|
|
2028
2829
|
// Flatten nested arrays if they appear (though grammar doesn't currently allow nesting).
|
|
2029
2830
|
if (Array.isArray(value)) {
|
|
@@ -2036,155 +2837,359 @@ class AbilityDSLParser {
|
|
|
2036
2837
|
}
|
|
2037
2838
|
else if (value === null) {
|
|
2038
2839
|
// Null is allowed in arrays? Currently, we throw.
|
|
2039
|
-
this.syntaxError('Unexpected null in array', this.peek());
|
|
2840
|
+
this.stream.syntaxError('Unexpected null in array', this.stream.peek());
|
|
2040
2841
|
}
|
|
2041
2842
|
// Optional comma between elements
|
|
2042
|
-
if (this.check(
|
|
2043
|
-
this.
|
|
2843
|
+
if (this.stream.check(TokenTypes.COMMA)) {
|
|
2844
|
+
this.stream.next();
|
|
2044
2845
|
}
|
|
2045
2846
|
}
|
|
2046
|
-
this.
|
|
2847
|
+
this.stream.expect(TokenTypes.RBRACKET, 'Expected "]"');
|
|
2047
2848
|
return arr;
|
|
2048
2849
|
}
|
|
2049
2850
|
// -------------------------------------------------------------------------
|
|
2050
|
-
// #region
|
|
2851
|
+
// #region comments
|
|
2051
2852
|
// -------------------------------------------------------------------------
|
|
2052
2853
|
consumeLeadingComments() {
|
|
2053
|
-
while (this.check(
|
|
2054
|
-
|
|
2055
|
-
this.processCommentToken(token);
|
|
2056
|
-
}
|
|
2057
|
-
}
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
const lineIdx = token.line - 1;
|
|
2081
|
-
const lineBefore = lineIdx > 0 ? lines[lineIdx - 1] : '';
|
|
2082
|
-
const current = lines[lineIdx];
|
|
2083
|
-
const linesAfter = lineIdx + 1 < lines.length ? lines[lineIdx + 1] : '';
|
|
2084
|
-
const wave = ' '.repeat(Math.max(0, token.column - 1)) + '~'.repeat(token.value.length);
|
|
2085
|
-
const lineNumWidth = String(token.line + 1).length;
|
|
2086
|
-
const num = (n) => String(n).padStart(lineNumWidth, ' ');
|
|
2087
|
-
let context = '';
|
|
2088
|
-
if (lineBefore.trim() !== '') {
|
|
2089
|
-
context += `${num(token.line - 1)} | ${lineBefore}\n`;
|
|
2090
|
-
}
|
|
2091
|
-
context += `${num(token.line)} | ${current}\n`;
|
|
2092
|
-
context += `${' '.repeat(lineNumWidth)} | ${wave}\n`;
|
|
2093
|
-
if (linesAfter.trim() !== '') {
|
|
2094
|
-
context += `${num(token.line + 1)} | ${linesAfter}`;
|
|
2095
|
-
}
|
|
2096
|
-
let finalDetails = details;
|
|
2097
|
-
if (expected && expected?.length > 0) {
|
|
2098
|
-
const actual = token.value;
|
|
2099
|
-
const suggestion = this.suggest(actual, expected);
|
|
2100
|
-
const detailsMsg = `${details}\nDetails: Unexpected value token \`${actual}\``;
|
|
2101
|
-
finalDetails = suggestion ? `${detailsMsg} Did you mean \`${suggestion}\`?` : detailsMsg;
|
|
2854
|
+
while (this.stream.check(TokenTypes.COMMENT)) {
|
|
2855
|
+
this.stream.next();
|
|
2856
|
+
// this.processCommentToken(token);
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
// private _consumeLeadingAnnotations() {
|
|
2860
|
+
// while (this.stream.check(TokenTypes.ANNOTATION)) {
|
|
2861
|
+
// const token = this.stream.next();
|
|
2862
|
+
// this.processAnnotationToken(token);
|
|
2863
|
+
// }
|
|
2864
|
+
// }
|
|
2865
|
+
consumeLeadingAliases() {
|
|
2866
|
+
while (this.stream.check(TokenTypes.ALIAS)) {
|
|
2867
|
+
this.stream.next(); // consume "alias"
|
|
2868
|
+
const nameToken = this.stream.expect(TokenTypes.IDENTIFIER, `Expected alias name`);
|
|
2869
|
+
const aliasKey = nameToken.value;
|
|
2870
|
+
this.stream.expect(TokenTypes.COLON, `Expected colon after an alias`);
|
|
2871
|
+
const annotations = this.takeAnnotations('alias');
|
|
2872
|
+
while (!this.stream.eof() && !this.isStartOfAlias() && !this.isStartOfPolicy()) {
|
|
2873
|
+
const rule = this.parseRule();
|
|
2874
|
+
rule.name = annotations.get('name')?.value || aliasKey;
|
|
2875
|
+
rule.description = annotations.get('description')?.value;
|
|
2876
|
+
if (annotations.get('disabled')?.value === true) {
|
|
2877
|
+
rule.disabled = true;
|
|
2878
|
+
}
|
|
2879
|
+
this.aliasBuffer.set(aliasKey, rule);
|
|
2880
|
+
}
|
|
2102
2881
|
}
|
|
2103
|
-
throw new AbilityDSLSyntaxError(token.line, token.column, context + '\n', finalDetails);
|
|
2104
2882
|
}
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
if (
|
|
2116
|
-
|
|
2117
|
-
|
|
2883
|
+
consumeLeadingAnnotations() {
|
|
2884
|
+
while (this.stream.check(TokenTypes.ANNOTATION)) {
|
|
2885
|
+
const token = this.stream.next();
|
|
2886
|
+
const text = token.value.trim();
|
|
2887
|
+
if (text.startsWith('@id ')) {
|
|
2888
|
+
this.annBuffer.setID(text.slice(4).trim(), token);
|
|
2889
|
+
}
|
|
2890
|
+
if (text.startsWith('@name ')) {
|
|
2891
|
+
this.annBuffer.setName(text.slice(6).trim(), token);
|
|
2892
|
+
}
|
|
2893
|
+
if (text.startsWith('@description ')) {
|
|
2894
|
+
this.annBuffer.setDescription(text.slice(13).trim(), token);
|
|
2895
|
+
}
|
|
2896
|
+
if (text.startsWith('@priority ')) {
|
|
2897
|
+
this.annBuffer.setPriority(parseInt(text.slice(10).trim(), 10), token);
|
|
2898
|
+
}
|
|
2899
|
+
if (text.startsWith('@disabled')) {
|
|
2900
|
+
const value = text.slice(9).trim();
|
|
2901
|
+
this.annBuffer.setDisabled(value.length === 0 ? true : text.slice(9).trim() === 'true', token);
|
|
2902
|
+
}
|
|
2903
|
+
if (text.startsWith('@tags ')) {
|
|
2904
|
+
const value = text
|
|
2905
|
+
.slice(6)
|
|
2906
|
+
.trim()
|
|
2907
|
+
.split(',')
|
|
2908
|
+
.map(tag => tag.trim());
|
|
2909
|
+
this.annBuffer.setTags(value, token);
|
|
2118
2910
|
}
|
|
2119
2911
|
}
|
|
2120
|
-
return best;
|
|
2121
2912
|
}
|
|
2122
|
-
|
|
2123
|
-
const
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
for (
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2913
|
+
takeAnnotations(owner) {
|
|
2914
|
+
const ann = this.annBuffer.clone();
|
|
2915
|
+
this.annBuffer.clear();
|
|
2916
|
+
const allowed = AnnotationAllowed[owner];
|
|
2917
|
+
for (const key of Object.keys(ann['store'])) {
|
|
2918
|
+
const entry = ann.get(key);
|
|
2919
|
+
if (!entry)
|
|
2920
|
+
continue;
|
|
2921
|
+
if (!allowed.has(key)) {
|
|
2922
|
+
this.stream.syntaxError(`Annotation @${key} is not allowed on ${owner}. Allowed: ${[...allowed]
|
|
2923
|
+
.map(a => '@' + a)
|
|
2924
|
+
.join(', ')}`, entry.token ?? this.stream.peek());
|
|
2132
2925
|
}
|
|
2133
2926
|
}
|
|
2134
|
-
return
|
|
2927
|
+
return ann;
|
|
2135
2928
|
}
|
|
2136
2929
|
// -------------------------------------------------------------------------
|
|
2137
|
-
// #region
|
|
2930
|
+
// #region Helpers
|
|
2138
2931
|
// -------------------------------------------------------------------------
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
for (const t of types) {
|
|
2142
|
-
if (token && token.code === t) {
|
|
2143
|
-
return this.advance();
|
|
2144
|
-
}
|
|
2145
|
-
}
|
|
2146
|
-
const expected = types.map(t => t).join(', ');
|
|
2147
|
-
const actual = token ? token.value : AbilityDSLToken.EOF;
|
|
2148
|
-
const suggestion = this.suggest(actual, types);
|
|
2149
|
-
const details = `${message}\nDetails: Unexpected token \`${actual}\`, expected one of: ${expected}.`;
|
|
2150
|
-
const finalMsg = suggestion ? `${details} Did you mean \`${suggestion}\`?` : details;
|
|
2151
|
-
this.syntaxError(finalMsg, token ?? this.tokens[this.tokens.length - 1]);
|
|
2932
|
+
isStartOfPolicy() {
|
|
2933
|
+
return this.stream.check(TokenTypes.EFFECT);
|
|
2152
2934
|
}
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2935
|
+
isStartOfGroup() {
|
|
2936
|
+
return this.stream.check(TokenTypes.ALL) || this.stream.check(TokenTypes.ANY);
|
|
2937
|
+
}
|
|
2938
|
+
isStartOfExcept() {
|
|
2939
|
+
return this.stream.check(TokenTypes.EXCEPT);
|
|
2940
|
+
}
|
|
2941
|
+
isStartOfAlias() {
|
|
2942
|
+
return this.stream.check(TokenTypes.ALIAS);
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
function ability(strings, ...expr) {
|
|
2947
|
+
const dsl = strings.reduce((acc, s, i) => acc + s + (expr[i] ?? ''), '');
|
|
2948
|
+
return new AbilityDSLParser(dsl).parse();
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
class AbilityStrategy {
|
|
2952
|
+
policies;
|
|
2953
|
+
matched;
|
|
2954
|
+
constructor(policies) {
|
|
2955
|
+
this.policies = policies;
|
|
2956
|
+
this.matched = policies.filter(p => p.matchState === AbilityMatch.match);
|
|
2957
|
+
}
|
|
2958
|
+
matchedPolicies() {
|
|
2959
|
+
return this.matched;
|
|
2960
|
+
}
|
|
2961
|
+
firstMatched() {
|
|
2962
|
+
return this.matched[0] ?? null;
|
|
2963
|
+
}
|
|
2964
|
+
lastMatched() {
|
|
2965
|
+
return this.matched.length > 0 ? this.matched[this.matched.length - 1] : null;
|
|
2966
|
+
}
|
|
2967
|
+
hasPermit() {
|
|
2968
|
+
return this.matched.some(p => p.effect === AbilityPolicyEffect.permit);
|
|
2969
|
+
}
|
|
2970
|
+
hasDeny() {
|
|
2971
|
+
return this.matched.some(p => p.effect === AbilityPolicyEffect.deny);
|
|
2972
|
+
}
|
|
2973
|
+
isAllowed() {
|
|
2974
|
+
return this.evaluate() === AbilityPolicyEffect.permit;
|
|
2975
|
+
}
|
|
2976
|
+
isDenied() {
|
|
2977
|
+
return this.evaluate() === AbilityPolicyEffect.deny;
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
/**
|
|
2982
|
+
* AllMustPermitStrategy
|
|
2983
|
+
*
|
|
2984
|
+
* This strategy requires *every applicable policy* to return "permit".
|
|
2985
|
+
* If at least one policy returns "deny" or "not applicable", the final result is "deny".
|
|
2986
|
+
*
|
|
2987
|
+
* Use this strategy when:
|
|
2988
|
+
* - You want strict, conservative access control.
|
|
2989
|
+
* - All rules must explicitly allow the action.
|
|
2990
|
+
*
|
|
2991
|
+
* Example:
|
|
2992
|
+
* Policies:
|
|
2993
|
+
* P1 → permit
|
|
2994
|
+
* P2 → permit
|
|
2995
|
+
* P3 → deny
|
|
2996
|
+
* Result: deny (because not all policies permitted)
|
|
2997
|
+
*/
|
|
2998
|
+
class AllMustPermitStrategy extends AbilityStrategy {
|
|
2999
|
+
evaluate() {
|
|
3000
|
+
const matched = this.matchedPolicies();
|
|
3001
|
+
if (matched.length === 0) {
|
|
3002
|
+
return AbilityPolicyEffect.deny;
|
|
2157
3003
|
}
|
|
2158
|
-
const
|
|
2159
|
-
|
|
2160
|
-
const suggestion = this.suggest(actual, [type]);
|
|
2161
|
-
const details = `${message}\nDetails: Unexpected token \`${actual}\`, expected "${expected}".`;
|
|
2162
|
-
const finalMsg = suggestion ? `${details} Did you mean \`${suggestion}\`?` : details;
|
|
2163
|
-
this.syntaxError(finalMsg, token ?? this.tokens[this.tokens.length - 1]);
|
|
3004
|
+
const allPermit = matched.every(p => p.effect === AbilityPolicyEffect.permit);
|
|
3005
|
+
return allPermit ? AbilityPolicyEffect.permit : AbilityPolicyEffect.deny;
|
|
2164
3006
|
}
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
/**
|
|
3010
|
+
* AnyPermitStrategy
|
|
3011
|
+
*
|
|
3012
|
+
* This strategy returns "permit" as soon as *any* applicable policy permits the action.
|
|
3013
|
+
* If no policy permits, the result is "deny".
|
|
3014
|
+
*
|
|
3015
|
+
* Use this strategy when:
|
|
3016
|
+
* - You want optimistic access control.
|
|
3017
|
+
* - A single positive rule should be enough to grant access.
|
|
3018
|
+
*
|
|
3019
|
+
* Example:
|
|
3020
|
+
* Policies:
|
|
3021
|
+
* P1 → deny
|
|
3022
|
+
* P2 → permit
|
|
3023
|
+
* P3 → deny
|
|
3024
|
+
* Result: permit (because at least one policy permitted)
|
|
3025
|
+
*/
|
|
3026
|
+
class AnyPermitStrategy extends AbilityStrategy {
|
|
3027
|
+
evaluate() {
|
|
3028
|
+
return this.hasPermit() ? AbilityPolicyEffect.permit : AbilityPolicyEffect.deny;
|
|
2169
3029
|
}
|
|
2170
|
-
|
|
2171
|
-
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
/**
|
|
3033
|
+
* DenyOverridesStrategy
|
|
3034
|
+
*
|
|
3035
|
+
* This strategy gives absolute priority to "deny".
|
|
3036
|
+
* If any applicable policy returns "deny", the final result is "deny".
|
|
3037
|
+
* Otherwise, if at least one policy permits, the result is "permit".
|
|
3038
|
+
*
|
|
3039
|
+
* Use this strategy when:
|
|
3040
|
+
* - Security is critical.
|
|
3041
|
+
* - A single denial must block access.
|
|
3042
|
+
*
|
|
3043
|
+
* Example:
|
|
3044
|
+
* Policies:
|
|
3045
|
+
* P1 → permit
|
|
3046
|
+
* P2 → deny
|
|
3047
|
+
* P3 → permit
|
|
3048
|
+
* Result: deny (because deny overrides everything)
|
|
3049
|
+
*/
|
|
3050
|
+
class DenyOverridesStrategy extends AbilityStrategy {
|
|
3051
|
+
evaluate() {
|
|
3052
|
+
if (this.hasDeny()) {
|
|
3053
|
+
return AbilityPolicyEffect.deny;
|
|
3054
|
+
}
|
|
3055
|
+
if (this.hasPermit()) {
|
|
3056
|
+
return AbilityPolicyEffect.permit;
|
|
3057
|
+
}
|
|
3058
|
+
return AbilityPolicyEffect.deny;
|
|
2172
3059
|
}
|
|
2173
|
-
|
|
2174
|
-
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
/**
|
|
3063
|
+
* FirstMatchStrategy
|
|
3064
|
+
*
|
|
3065
|
+
* This strategy evaluates policies in order and returns the result of the *first applicable* policy.
|
|
3066
|
+
* Remaining policies are ignored.
|
|
3067
|
+
*
|
|
3068
|
+
* Use this strategy when:
|
|
3069
|
+
* - Policy order matters.
|
|
3070
|
+
* - You want predictable, sequential rule evaluation.
|
|
3071
|
+
*
|
|
3072
|
+
* Example:
|
|
3073
|
+
* Policies:
|
|
3074
|
+
* P1 → not applicable
|
|
3075
|
+
* P2 → permit
|
|
3076
|
+
* P3 → deny
|
|
3077
|
+
* Result: permit (P2 is the first applicable)
|
|
3078
|
+
*/
|
|
3079
|
+
class FirstMatchStrategy extends AbilityStrategy {
|
|
3080
|
+
evaluate() {
|
|
3081
|
+
const first = this.firstMatched();
|
|
3082
|
+
return first?.effect ?? AbilityPolicyEffect.deny;
|
|
2175
3083
|
}
|
|
2176
|
-
|
|
2177
|
-
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
/**
|
|
3087
|
+
* OnlyOneApplicableStrategy
|
|
3088
|
+
*
|
|
3089
|
+
* This strategy requires that *exactly one* policy is applicable.
|
|
3090
|
+
* If zero or more than one policy applies, the result is "deny".
|
|
3091
|
+
*
|
|
3092
|
+
* Use this strategy when:
|
|
3093
|
+
* - Policies must be mutually exclusive.
|
|
3094
|
+
* - You want to detect ambiguous or conflicting rules.
|
|
3095
|
+
*
|
|
3096
|
+
* Example:
|
|
3097
|
+
* Policies:
|
|
3098
|
+
* P1 → applicable
|
|
3099
|
+
* P2 → applicable
|
|
3100
|
+
* Result: deny (more than one applicable policy)
|
|
3101
|
+
*/
|
|
3102
|
+
class OnlyOneApplicableStrategy extends AbilityStrategy {
|
|
3103
|
+
evaluate() {
|
|
3104
|
+
const matched = this.matchedPolicies();
|
|
3105
|
+
if (matched.length === 1) {
|
|
3106
|
+
return matched[0].effect;
|
|
3107
|
+
}
|
|
3108
|
+
return AbilityPolicyEffect.deny;
|
|
2178
3109
|
}
|
|
2179
|
-
|
|
2180
|
-
|
|
3110
|
+
}
|
|
3111
|
+
|
|
3112
|
+
/**
|
|
3113
|
+
* PermitOverridesStrategy
|
|
3114
|
+
*
|
|
3115
|
+
* This strategy gives priority to "permit".
|
|
3116
|
+
* If any applicable policy permits, the final result is "permit".
|
|
3117
|
+
* Deny is returned only if no policy permits.
|
|
3118
|
+
*
|
|
3119
|
+
* Use this strategy when:
|
|
3120
|
+
* - You want permissive behavior.
|
|
3121
|
+
* - A single positive rule should override denials.
|
|
3122
|
+
*
|
|
3123
|
+
* Example:
|
|
3124
|
+
* Policies:
|
|
3125
|
+
* P1 → deny
|
|
3126
|
+
* P2 → permit
|
|
3127
|
+
* P3 → deny
|
|
3128
|
+
* Result: permit (permit overrides deny)
|
|
3129
|
+
*/
|
|
3130
|
+
class PermitOverridesStrategy extends AbilityStrategy {
|
|
3131
|
+
evaluate() {
|
|
3132
|
+
if (this.hasPermit()) {
|
|
3133
|
+
return AbilityPolicyEffect.permit;
|
|
3134
|
+
}
|
|
3135
|
+
if (this.hasDeny()) {
|
|
3136
|
+
return AbilityPolicyEffect.deny;
|
|
3137
|
+
}
|
|
3138
|
+
return AbilityPolicyEffect.deny;
|
|
2181
3139
|
}
|
|
2182
|
-
|
|
2183
|
-
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
/**
|
|
3143
|
+
* SequentialLastMatchStrategy
|
|
3144
|
+
*
|
|
3145
|
+
* This strategy evaluates all applicable policies in order and returns the result of the *last* applicable one.
|
|
3146
|
+
*
|
|
3147
|
+
* Use this strategy when:
|
|
3148
|
+
* - Later policies should override earlier ones.
|
|
3149
|
+
* - You want a "last rule wins" behavior.
|
|
3150
|
+
*
|
|
3151
|
+
* Example:
|
|
3152
|
+
* Policies:
|
|
3153
|
+
* P1 → permit
|
|
3154
|
+
* P2 → deny
|
|
3155
|
+
* P3 → permit
|
|
3156
|
+
* Result: permit (P3 is the last applicable)
|
|
3157
|
+
*/
|
|
3158
|
+
class SequentialLastMatchStrategy extends AbilityStrategy {
|
|
3159
|
+
evaluate() {
|
|
3160
|
+
const last = this.lastMatched();
|
|
3161
|
+
return last?.effect ?? AbilityPolicyEffect.deny;
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
/**
|
|
3166
|
+
* PriorityStrategy
|
|
3167
|
+
*
|
|
3168
|
+
* This strategy evaluates policies based on their numeric priority.
|
|
3169
|
+
* The policy with the highest priority (lowest number or highest number depending on implementation)
|
|
3170
|
+
* determines the final result.
|
|
3171
|
+
*
|
|
3172
|
+
* Use this strategy when:
|
|
3173
|
+
* - Policies have explicit priority levels.
|
|
3174
|
+
* - You want deterministic resolution based on ranking.
|
|
3175
|
+
*
|
|
3176
|
+
* Example:
|
|
3177
|
+
* Policies:
|
|
3178
|
+
* P1 (priority 10) → deny
|
|
3179
|
+
* P2 (priority 1) → permit
|
|
3180
|
+
* Result: permit (P2 has higher priority)
|
|
3181
|
+
*/
|
|
3182
|
+
class PriorityStrategy extends AbilityStrategy {
|
|
3183
|
+
evaluate() {
|
|
3184
|
+
const matched = this.matchedPolicies();
|
|
3185
|
+
if (matched.length === 0) {
|
|
3186
|
+
return AbilityPolicyEffect.deny;
|
|
3187
|
+
}
|
|
3188
|
+
const sorted = [...matched].sort((a, b) => b.priority - a.priority);
|
|
3189
|
+
return sorted[0].effect;
|
|
2184
3190
|
}
|
|
2185
3191
|
}
|
|
2186
3192
|
|
|
2187
|
-
exports.AbilityCode = AbilityCode;
|
|
2188
3193
|
exports.AbilityCompare = AbilityCompare;
|
|
2189
3194
|
exports.AbilityCondition = AbilityCondition;
|
|
2190
3195
|
exports.AbilityDSLLexer = AbilityDSLLexer;
|
|
@@ -2202,7 +3207,21 @@ exports.AbilityPolicy = AbilityPolicy;
|
|
|
2202
3207
|
exports.AbilityPolicyEffect = AbilityPolicyEffect;
|
|
2203
3208
|
exports.AbilityResolver = AbilityResolver;
|
|
2204
3209
|
exports.AbilityResult = AbilityResult;
|
|
2205
|
-
exports.AbilityResultState = AbilityResultState;
|
|
2206
3210
|
exports.AbilityRule = AbilityRule;
|
|
2207
3211
|
exports.AbilityRuleSet = AbilityRuleSet;
|
|
3212
|
+
exports.AbilityStrategy = AbilityStrategy;
|
|
2208
3213
|
exports.AbilityTypeGenerator = AbilityTypeGenerator;
|
|
3214
|
+
exports.AllMustPermitStrategy = AllMustPermitStrategy;
|
|
3215
|
+
exports.AnyPermitStrategy = AnyPermitStrategy;
|
|
3216
|
+
exports.DenyOverridesStrategy = DenyOverridesStrategy;
|
|
3217
|
+
exports.FirstMatchStrategy = FirstMatchStrategy;
|
|
3218
|
+
exports.OnlyOneApplicableStrategy = OnlyOneApplicableStrategy;
|
|
3219
|
+
exports.PermitOverridesStrategy = PermitOverridesStrategy;
|
|
3220
|
+
exports.PriorityStrategy = PriorityStrategy;
|
|
3221
|
+
exports.SequentialLastMatchStrategy = SequentialLastMatchStrategy;
|
|
3222
|
+
exports.TokenTypes = TokenTypes;
|
|
3223
|
+
exports.ability = ability;
|
|
3224
|
+
exports.fromLiteral = fromLiteral;
|
|
3225
|
+
exports.isConditionEqual = isConditionEqual;
|
|
3226
|
+
exports.isConditionNotEqual = isConditionNotEqual;
|
|
3227
|
+
exports.toLiteral = toLiteral;
|