odgn-rights 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +368 -0
- package/dist/adapters/base-adapter.d.ts +83 -0
- package/dist/adapters/base-adapter.js +142 -0
- package/dist/adapters/factories.d.ts +31 -0
- package/dist/adapters/factories.js +48 -0
- package/dist/adapters/index.d.ts +11 -0
- package/dist/adapters/index.js +12 -0
- package/dist/adapters/postgres-adapter.d.ts +51 -0
- package/dist/adapters/postgres-adapter.js +469 -0
- package/dist/adapters/redis-adapter.d.ts +84 -0
- package/dist/adapters/redis-adapter.js +673 -0
- package/dist/adapters/schema.d.ts +25 -0
- package/dist/adapters/schema.js +186 -0
- package/dist/adapters/sqlite-adapter.d.ts +78 -0
- package/dist/adapters/sqlite-adapter.js +655 -0
- package/dist/adapters/types.d.ts +174 -0
- package/dist/adapters/types.js +1 -0
- package/dist/cli/commands/explain.js +13 -5
- package/dist/helpers.d.ts +16 -0
- package/dist/{utils.js → helpers.js} +22 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/integrations/elysia.d.ts +235 -0
- package/dist/integrations/elysia.js +375 -0
- package/dist/right.d.ts +7 -0
- package/dist/right.js +63 -8
- package/dist/rights.d.ts +19 -0
- package/dist/rights.js +48 -2
- package/dist/role-registry.d.ts +9 -0
- package/dist/role-registry.js +12 -0
- package/dist/subject-registry.d.ts +77 -0
- package/dist/subject-registry.js +123 -0
- package/dist/subject.d.ts +4 -0
- package/dist/subject.js +3 -0
- package/package.json +41 -6
- package/dist/utils.d.ts +0 -2
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ElysiaJS integration for odgn-rights
|
|
3
|
+
*
|
|
4
|
+
* @module odgn-rights/integrations/elysia
|
|
5
|
+
*
|
|
6
|
+
* This module provides middleware and utilities for integrating
|
|
7
|
+
* odgn-rights with ElysiaJS applications.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { Elysia } from 'elysia';
|
|
12
|
+
* import { elysiaRights } from 'odgn-rights/integrations/elysia';
|
|
13
|
+
*
|
|
14
|
+
* const app = new Elysia()
|
|
15
|
+
* .use(elysiaRights({
|
|
16
|
+
* registry: subjectRegistry,
|
|
17
|
+
* getSubject: ({ headers }) => headers.get('x-user-id')
|
|
18
|
+
* }))
|
|
19
|
+
* .get('/users', () => 'list users')
|
|
20
|
+
* .listen(3000);
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* HTTP method to Flags mapping
|
|
25
|
+
*/
|
|
26
|
+
const DEFAULT_METHOD_FLAG_MAP = {
|
|
27
|
+
DELETE: 4, // DELETE
|
|
28
|
+
GET: 1, // READ
|
|
29
|
+
HEAD: 1,
|
|
30
|
+
OPTIONS: 1,
|
|
31
|
+
PATCH: 2,
|
|
32
|
+
POST: 8, // CREATE
|
|
33
|
+
PUT: 2 // WRITE
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Create an Elysia plugin for rights-based authorization.
|
|
37
|
+
*
|
|
38
|
+
* @example Using with SubjectRegistry
|
|
39
|
+
* ```typescript
|
|
40
|
+
* import { Elysia } from 'elysia';
|
|
41
|
+
* import { elysiaRights } from 'odgn-rights/integrations/elysia';
|
|
42
|
+
*
|
|
43
|
+
* const app = new Elysia()
|
|
44
|
+
* .use(elysiaRights({
|
|
45
|
+
* registry: subjectRegistry,
|
|
46
|
+
* getSubject: ({ headers }) => headers.get('x-user-id')
|
|
47
|
+
* }))
|
|
48
|
+
* .get('/users', () => 'list users')
|
|
49
|
+
* .listen(3000);
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* @example Using with direct Subject
|
|
53
|
+
* ```typescript
|
|
54
|
+
* const app = new Elysia()
|
|
55
|
+
* .derive(({ headers }) => ({
|
|
56
|
+
* user: getUserFromToken(headers.get('authorization'))
|
|
57
|
+
* }))
|
|
58
|
+
* .use(elysiaRights({
|
|
59
|
+
* getSubject: ({ user }) => user?.subject
|
|
60
|
+
* }))
|
|
61
|
+
* .get('/users', () => 'list users')
|
|
62
|
+
* .listen(3000);
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export const elysiaRights = (options) => {
|
|
66
|
+
// Dynamic import to avoid requiring elysia as a hard dependency
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
68
|
+
const { Elysia } = require('elysia');
|
|
69
|
+
const { flagMapper, getContext, getSubject, onNoSubject, onUnauthorized, pathMapper, registry } = options;
|
|
70
|
+
return new Elysia({ name: 'odgn-rights' }).onBeforeHandle({ as: 'scoped' }, async ({ path, request, set, ...rest }) => {
|
|
71
|
+
const method = request.method;
|
|
72
|
+
// Get the subject
|
|
73
|
+
const subjectOrId = await getSubject({
|
|
74
|
+
headers: request.headers,
|
|
75
|
+
path,
|
|
76
|
+
request,
|
|
77
|
+
...rest
|
|
78
|
+
});
|
|
79
|
+
let subject;
|
|
80
|
+
if (subjectOrId === null || subjectOrId === undefined) {
|
|
81
|
+
if (onNoSubject) {
|
|
82
|
+
return onNoSubject({ path, request });
|
|
83
|
+
}
|
|
84
|
+
set.status = 401;
|
|
85
|
+
return { error: 'Unauthorized', message: 'No subject found' };
|
|
86
|
+
}
|
|
87
|
+
if (typeof subjectOrId === 'string') {
|
|
88
|
+
if (!registry) {
|
|
89
|
+
throw new Error('elysiaRights: registry is required when getSubject returns a string identifier');
|
|
90
|
+
}
|
|
91
|
+
subject = registry.get(subjectOrId);
|
|
92
|
+
if (!subject) {
|
|
93
|
+
if (onNoSubject) {
|
|
94
|
+
return onNoSubject({ path, request });
|
|
95
|
+
}
|
|
96
|
+
set.status = 401;
|
|
97
|
+
return { error: 'Unauthorized', message: 'Subject not found' };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
subject = subjectOrId;
|
|
102
|
+
}
|
|
103
|
+
// Map the path
|
|
104
|
+
const rightsPath = pathMapper
|
|
105
|
+
? pathMapper({ method, path, request })
|
|
106
|
+
: path;
|
|
107
|
+
// Get the required flags
|
|
108
|
+
const requiredFlags = flagMapper
|
|
109
|
+
? flagMapper({ method, path, request })
|
|
110
|
+
: (DEFAULT_METHOD_FLAG_MAP[method] ?? 1); // Default to READ
|
|
111
|
+
// Get condition context if provided
|
|
112
|
+
const context = getContext
|
|
113
|
+
? await getContext({
|
|
114
|
+
headers: request.headers,
|
|
115
|
+
path,
|
|
116
|
+
request,
|
|
117
|
+
subject,
|
|
118
|
+
...rest
|
|
119
|
+
})
|
|
120
|
+
: undefined;
|
|
121
|
+
// Check permission
|
|
122
|
+
const allowed = subject.has(rightsPath, requiredFlags, context);
|
|
123
|
+
if (!allowed) {
|
|
124
|
+
if (onUnauthorized) {
|
|
125
|
+
return onUnauthorized({
|
|
126
|
+
path: rightsPath,
|
|
127
|
+
request,
|
|
128
|
+
requiredFlags,
|
|
129
|
+
subject
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
set.status = 403;
|
|
133
|
+
return { error: 'Forbidden', message: 'Access denied' };
|
|
134
|
+
}
|
|
135
|
+
// Permission granted, continue to handler
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* Create an Elysia plugin for rights-based authorization using a Rights instance directly.
|
|
140
|
+
* Use this when you don't need subject/role-based access control.
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* ```typescript
|
|
144
|
+
* import { Elysia } from 'elysia';
|
|
145
|
+
* import { elysiaRightsStandalone } from 'odgn-rights/integrations/elysia';
|
|
146
|
+
* import { Rights, Flags } from 'odgn-rights';
|
|
147
|
+
*
|
|
148
|
+
* const rights = new Rights();
|
|
149
|
+
* rights.allow('/api/**', Flags.READ);
|
|
150
|
+
* rights.allow('/api/admin/**', Flags.ALL);
|
|
151
|
+
*
|
|
152
|
+
* const app = new Elysia()
|
|
153
|
+
* .use(elysiaRightsStandalone({ rights }))
|
|
154
|
+
* .get('/api/users', () => 'list users')
|
|
155
|
+
* .listen(3000);
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
export const elysiaRightsStandalone = (options) => {
|
|
159
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
160
|
+
const { Elysia } = require('elysia');
|
|
161
|
+
const { flagMapper, getContext, onUnauthorized, pathMapper, rights } = options;
|
|
162
|
+
return new Elysia({ name: 'odgn-rights-standalone' }).onBeforeHandle({ as: 'scoped' }, async ({ path, request, set, ...rest }) => {
|
|
163
|
+
const method = request.method;
|
|
164
|
+
// Map the path
|
|
165
|
+
const rightsPath = pathMapper
|
|
166
|
+
? pathMapper({ method, path, request })
|
|
167
|
+
: path;
|
|
168
|
+
// Get the required flags
|
|
169
|
+
const requiredFlags = flagMapper
|
|
170
|
+
? flagMapper({ method, path, request })
|
|
171
|
+
: (DEFAULT_METHOD_FLAG_MAP[method] ?? 1);
|
|
172
|
+
// Get condition context if provided
|
|
173
|
+
const context = getContext
|
|
174
|
+
? await getContext({
|
|
175
|
+
headers: request.headers,
|
|
176
|
+
path,
|
|
177
|
+
request,
|
|
178
|
+
subject: undefined,
|
|
179
|
+
...rest
|
|
180
|
+
})
|
|
181
|
+
: undefined;
|
|
182
|
+
// Check permission
|
|
183
|
+
const allowed = rights.has(rightsPath, requiredFlags, context);
|
|
184
|
+
if (!allowed) {
|
|
185
|
+
if (onUnauthorized) {
|
|
186
|
+
return onUnauthorized({
|
|
187
|
+
path: rightsPath,
|
|
188
|
+
request,
|
|
189
|
+
requiredFlags,
|
|
190
|
+
subject: undefined
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
set.status = 403;
|
|
194
|
+
return { error: 'Forbidden', message: 'Access denied' };
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
};
|
|
198
|
+
/**
|
|
199
|
+
* Create a guard configuration for use with Elysia's .guard() method.
|
|
200
|
+
* This allows more fine-grained control over which routes are protected.
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* ```typescript
|
|
204
|
+
* import { Elysia } from 'elysia';
|
|
205
|
+
* import { createRightsGuard } from 'odgn-rights/integrations/elysia';
|
|
206
|
+
*
|
|
207
|
+
* const guard = createRightsGuard({
|
|
208
|
+
* registry: subjectRegistry,
|
|
209
|
+
* getSubject: ({ headers }) => headers.get('x-user-id')
|
|
210
|
+
* });
|
|
211
|
+
*
|
|
212
|
+
* const app = new Elysia()
|
|
213
|
+
* .get('/public', () => 'anyone can see this')
|
|
214
|
+
* .guard(guard, (app) =>
|
|
215
|
+
* app
|
|
216
|
+
* .get('/protected', () => 'only authorized users')
|
|
217
|
+
* .post('/protected', () => 'create something')
|
|
218
|
+
* )
|
|
219
|
+
* .listen(3000);
|
|
220
|
+
* ```
|
|
221
|
+
*/
|
|
222
|
+
export const createRightsGuard = (options) => {
|
|
223
|
+
const { flagMapper, getContext, getSubject, onNoSubject, onUnauthorized, pathMapper, registry } = options;
|
|
224
|
+
return {
|
|
225
|
+
async beforeHandle({ path, request, set, ...rest }) {
|
|
226
|
+
const method = request.method;
|
|
227
|
+
const subjectOrId = await getSubject({
|
|
228
|
+
headers: request.headers,
|
|
229
|
+
path,
|
|
230
|
+
request,
|
|
231
|
+
...rest
|
|
232
|
+
});
|
|
233
|
+
let subject;
|
|
234
|
+
if (subjectOrId === null || subjectOrId === undefined) {
|
|
235
|
+
if (onNoSubject) {
|
|
236
|
+
return onNoSubject({ path, request });
|
|
237
|
+
}
|
|
238
|
+
set.status = 401;
|
|
239
|
+
return { error: 'Unauthorized', message: 'No subject found' };
|
|
240
|
+
}
|
|
241
|
+
if (typeof subjectOrId === 'string') {
|
|
242
|
+
if (!registry) {
|
|
243
|
+
throw new Error('createRightsGuard: registry is required when getSubject returns a string identifier');
|
|
244
|
+
}
|
|
245
|
+
subject = registry.get(subjectOrId);
|
|
246
|
+
if (!subject) {
|
|
247
|
+
if (onNoSubject) {
|
|
248
|
+
return onNoSubject({ path, request });
|
|
249
|
+
}
|
|
250
|
+
set.status = 401;
|
|
251
|
+
return { error: 'Unauthorized', message: 'Subject not found' };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
subject = subjectOrId;
|
|
256
|
+
}
|
|
257
|
+
const rightsPath = pathMapper
|
|
258
|
+
? pathMapper({ method, path, request })
|
|
259
|
+
: path;
|
|
260
|
+
const requiredFlags = flagMapper
|
|
261
|
+
? flagMapper({ method, path, request })
|
|
262
|
+
: (DEFAULT_METHOD_FLAG_MAP[method] ?? 1);
|
|
263
|
+
const context = getContext
|
|
264
|
+
? await getContext({
|
|
265
|
+
headers: request.headers,
|
|
266
|
+
path,
|
|
267
|
+
request,
|
|
268
|
+
subject,
|
|
269
|
+
...rest
|
|
270
|
+
})
|
|
271
|
+
: undefined;
|
|
272
|
+
const allowed = subject.has(rightsPath, requiredFlags, context);
|
|
273
|
+
if (!allowed) {
|
|
274
|
+
if (onUnauthorized) {
|
|
275
|
+
return onUnauthorized({
|
|
276
|
+
path: rightsPath,
|
|
277
|
+
request,
|
|
278
|
+
requiredFlags,
|
|
279
|
+
subject
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
set.status = 403;
|
|
283
|
+
return { error: 'Forbidden', message: 'Access denied' };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
};
|
|
288
|
+
/**
|
|
289
|
+
* Create a macro for declarative per-route authorization.
|
|
290
|
+
* This allows you to specify rights requirements directly on route definitions.
|
|
291
|
+
*
|
|
292
|
+
* @example
|
|
293
|
+
* ```typescript
|
|
294
|
+
* import { Elysia } from 'elysia';
|
|
295
|
+
* import { createRightsMacro } from 'odgn-rights/integrations/elysia';
|
|
296
|
+
* import { Flags } from 'odgn-rights';
|
|
297
|
+
*
|
|
298
|
+
* const rightsMacro = createRightsMacro({
|
|
299
|
+
* registry: subjectRegistry,
|
|
300
|
+
* getSubject: ({ headers }) => headers.get('x-user-id')
|
|
301
|
+
* });
|
|
302
|
+
*
|
|
303
|
+
* const app = new Elysia()
|
|
304
|
+
* .use(rightsMacro)
|
|
305
|
+
* .get('/public', () => 'anyone')
|
|
306
|
+
* .get('/users', () => 'list users', {
|
|
307
|
+
* rights: { path: '/users', flags: Flags.READ }
|
|
308
|
+
* })
|
|
309
|
+
* .delete('/users/:id', () => 'delete user', {
|
|
310
|
+
* rights: { path: '/users/*', flags: Flags.DELETE }
|
|
311
|
+
* })
|
|
312
|
+
* .listen(3000);
|
|
313
|
+
* ```
|
|
314
|
+
*/
|
|
315
|
+
export const createRightsMacro = (options) => {
|
|
316
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
317
|
+
const { Elysia } = require('elysia');
|
|
318
|
+
const { getContext, getSubject, onNoSubject, onUnauthorized, registry } = options;
|
|
319
|
+
return new Elysia({ name: 'odgn-rights-macro' }).macro({
|
|
320
|
+
rights: (rightsConfig) => ({
|
|
321
|
+
async resolve({ path, request, set, status, ...rest }) {
|
|
322
|
+
const subjectOrId = await getSubject({
|
|
323
|
+
headers: request.headers,
|
|
324
|
+
path,
|
|
325
|
+
request,
|
|
326
|
+
...rest
|
|
327
|
+
});
|
|
328
|
+
let subject;
|
|
329
|
+
if (subjectOrId === null || subjectOrId === undefined) {
|
|
330
|
+
if (onNoSubject) {
|
|
331
|
+
return onNoSubject({ path, request });
|
|
332
|
+
}
|
|
333
|
+
return status(401);
|
|
334
|
+
}
|
|
335
|
+
if (typeof subjectOrId === 'string') {
|
|
336
|
+
if (!registry) {
|
|
337
|
+
throw new Error('createRightsMacro: registry is required when getSubject returns a string identifier');
|
|
338
|
+
}
|
|
339
|
+
subject = registry.get(subjectOrId);
|
|
340
|
+
if (!subject) {
|
|
341
|
+
if (onNoSubject) {
|
|
342
|
+
return onNoSubject({ path, request });
|
|
343
|
+
}
|
|
344
|
+
return status(401);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
subject = subjectOrId;
|
|
349
|
+
}
|
|
350
|
+
const context = getContext
|
|
351
|
+
? await getContext({
|
|
352
|
+
headers: request.headers,
|
|
353
|
+
path,
|
|
354
|
+
request,
|
|
355
|
+
subject,
|
|
356
|
+
...rest
|
|
357
|
+
})
|
|
358
|
+
: undefined;
|
|
359
|
+
const allowed = subject.has(rightsConfig.path, rightsConfig.flags, context);
|
|
360
|
+
if (!allowed) {
|
|
361
|
+
if (onUnauthorized) {
|
|
362
|
+
return onUnauthorized({
|
|
363
|
+
path: rightsConfig.path,
|
|
364
|
+
request,
|
|
365
|
+
requiredFlags: rightsConfig.flags,
|
|
366
|
+
subject
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
return status(403);
|
|
370
|
+
}
|
|
371
|
+
return { subject };
|
|
372
|
+
}
|
|
373
|
+
})
|
|
374
|
+
});
|
|
375
|
+
};
|
package/dist/right.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export type RightInit = {
|
|
|
6
6
|
condition?: Condition;
|
|
7
7
|
deny?: Flags[];
|
|
8
8
|
description?: string;
|
|
9
|
+
priority?: number;
|
|
9
10
|
tags?: string[];
|
|
10
11
|
validFrom?: Date;
|
|
11
12
|
validUntil?: Date;
|
|
@@ -20,7 +21,9 @@ export declare class Right {
|
|
|
20
21
|
readonly validUntil?: Date;
|
|
21
22
|
private readonly _tags;
|
|
22
23
|
private readonly _specificity;
|
|
24
|
+
private readonly _priority;
|
|
23
25
|
private readonly _re?;
|
|
26
|
+
private _dbId?;
|
|
24
27
|
constructor(path: string, init?: RightInit);
|
|
25
28
|
allow(flag: Flags): this;
|
|
26
29
|
deny(flag: Flags): this;
|
|
@@ -35,18 +38,22 @@ export declare class Right {
|
|
|
35
38
|
isExpired(now?: Date): boolean;
|
|
36
39
|
get allowMaskValue(): number;
|
|
37
40
|
get denyMaskValue(): number;
|
|
41
|
+
get dbId(): number | undefined;
|
|
42
|
+
_setDbId(id: number): void;
|
|
38
43
|
toString(): string;
|
|
39
44
|
toJSON(): {
|
|
40
45
|
allow: string;
|
|
41
46
|
deny?: string;
|
|
42
47
|
description?: string;
|
|
43
48
|
path: string;
|
|
49
|
+
priority?: number;
|
|
44
50
|
tags?: string[];
|
|
45
51
|
validFrom?: string;
|
|
46
52
|
validUntil?: string;
|
|
47
53
|
};
|
|
48
54
|
matches(targetPath: string): boolean;
|
|
49
55
|
specificity(): number;
|
|
56
|
+
get priority(): number;
|
|
50
57
|
private calculateSpecificity;
|
|
51
58
|
private static globToRegExp;
|
|
52
59
|
static parse(input: string): Right;
|
package/dist/right.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { ALL_BITS, Flags, hasBit } from './constants';
|
|
2
|
-
import { lettersFromMask, normalizePath } from './
|
|
2
|
+
import { lettersFromMask, normalizePath, parsePath } from './helpers';
|
|
3
3
|
export class Right {
|
|
4
4
|
constructor(path, init) {
|
|
5
5
|
this.allowMask = 0;
|
|
6
6
|
this.denyMask = 0;
|
|
7
|
-
|
|
7
|
+
const parsed = parsePath(path);
|
|
8
|
+
this.path = parsed.path;
|
|
8
9
|
this.description = init?.description;
|
|
9
10
|
this.condition = init?.condition;
|
|
10
11
|
this.validFrom = init?.validFrom;
|
|
11
12
|
this.validUntil = init?.validUntil;
|
|
12
13
|
this._tags = new Set(init?.tags);
|
|
14
|
+
this._priority = init?.priority ?? 0;
|
|
13
15
|
if (this.validFrom && this.validUntil && this.validFrom > this.validUntil) {
|
|
14
16
|
throw new Error('validFrom must be before validUntil');
|
|
15
17
|
}
|
|
@@ -17,11 +19,23 @@ export class Right {
|
|
|
17
19
|
if (this.path.includes('*') || this.path.includes('?')) {
|
|
18
20
|
this._re = Right.globToRegExp(this.path);
|
|
19
21
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
// When path is negated (!path), swap allow and deny semantics
|
|
23
|
+
// allow becomes deny, deny becomes allow
|
|
24
|
+
if (parsed.negated) {
|
|
25
|
+
if (init?.allow) {
|
|
26
|
+
init.allow.forEach(f => this.deny(f));
|
|
27
|
+
}
|
|
28
|
+
if (init?.deny) {
|
|
29
|
+
init.deny.forEach(f => this.allow(f));
|
|
30
|
+
}
|
|
22
31
|
}
|
|
23
|
-
|
|
24
|
-
|
|
32
|
+
else {
|
|
33
|
+
if (init?.allow) {
|
|
34
|
+
init.allow.forEach(f => this.allow(f));
|
|
35
|
+
}
|
|
36
|
+
if (init?.deny) {
|
|
37
|
+
init.deny.forEach(f => this.deny(f));
|
|
38
|
+
}
|
|
25
39
|
}
|
|
26
40
|
}
|
|
27
41
|
allow(flag) {
|
|
@@ -102,6 +116,12 @@ export class Right {
|
|
|
102
116
|
get denyMaskValue() {
|
|
103
117
|
return this.denyMask;
|
|
104
118
|
}
|
|
119
|
+
get dbId() {
|
|
120
|
+
return this._dbId;
|
|
121
|
+
}
|
|
122
|
+
_setDbId(id) {
|
|
123
|
+
this._dbId = id;
|
|
124
|
+
}
|
|
105
125
|
toString() {
|
|
106
126
|
const denyLetters = lettersFromMask(this.denyMask);
|
|
107
127
|
const allowLetters = lettersFromMask(this.allowMask);
|
|
@@ -114,6 +134,9 @@ export class Right {
|
|
|
114
134
|
}
|
|
115
135
|
const left = parts.join('');
|
|
116
136
|
let res = `${left}:${this.path}`;
|
|
137
|
+
if (this._priority !== 0) {
|
|
138
|
+
res += `^${this._priority}`;
|
|
139
|
+
}
|
|
117
140
|
if (this._tags.size > 0) {
|
|
118
141
|
res += `#${this.tags.join(',')}`;
|
|
119
142
|
}
|
|
@@ -137,6 +160,9 @@ export class Right {
|
|
|
137
160
|
if (this.description) {
|
|
138
161
|
out.description = this.description;
|
|
139
162
|
}
|
|
163
|
+
if (this._priority !== 0) {
|
|
164
|
+
out.priority = this._priority;
|
|
165
|
+
}
|
|
140
166
|
if (this._tags.size > 0) {
|
|
141
167
|
out.tags = this.tags;
|
|
142
168
|
}
|
|
@@ -164,6 +190,9 @@ export class Right {
|
|
|
164
190
|
specificity() {
|
|
165
191
|
return this._specificity;
|
|
166
192
|
}
|
|
193
|
+
get priority() {
|
|
194
|
+
return this._priority;
|
|
195
|
+
}
|
|
167
196
|
calculateSpecificity() {
|
|
168
197
|
const parts = this.path.split('/').filter(p => p.length > 0);
|
|
169
198
|
let literalCount = 0;
|
|
@@ -213,6 +242,7 @@ export class Right {
|
|
|
213
242
|
let pathStr = '';
|
|
214
243
|
let tagsStr = '';
|
|
215
244
|
let timeStr = '';
|
|
245
|
+
let priorityStr = '';
|
|
216
246
|
let pathEndIdx = s.length;
|
|
217
247
|
if (atIdx !== -1) {
|
|
218
248
|
timeStr = s.slice(atIdx + 1);
|
|
@@ -222,6 +252,13 @@ export class Right {
|
|
|
222
252
|
tagsStr = s.slice(hashIdx + 1, pathEndIdx);
|
|
223
253
|
pathEndIdx = hashIdx;
|
|
224
254
|
}
|
|
255
|
+
// Find ^ for priority (between path and #/@)
|
|
256
|
+
const pathStartIdx = colonIdx === -1 ? 0 : colonIdx + 1;
|
|
257
|
+
const caretIdx = s.indexOf('^', pathStartIdx);
|
|
258
|
+
if (caretIdx !== -1 && caretIdx < pathEndIdx) {
|
|
259
|
+
priorityStr = s.slice(caretIdx + 1, pathEndIdx);
|
|
260
|
+
pathEndIdx = caretIdx;
|
|
261
|
+
}
|
|
225
262
|
if (colonIdx === -1) {
|
|
226
263
|
pathStr = s.slice(0, pathEndIdx);
|
|
227
264
|
}
|
|
@@ -250,11 +287,20 @@ export class Right {
|
|
|
250
287
|
if (tagsStr) {
|
|
251
288
|
init.tags = tagsStr.split(',').map(t => t.trim());
|
|
252
289
|
}
|
|
253
|
-
|
|
290
|
+
if (priorityStr) {
|
|
291
|
+
const p = Number.parseInt(priorityStr, 10);
|
|
292
|
+
if (!Number.isNaN(p)) {
|
|
293
|
+
init.priority = p;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// Check if path is negated (starts with !)
|
|
297
|
+
const parsed = parsePath(pathStr);
|
|
298
|
+
const r = new Right(parsed.path, init);
|
|
254
299
|
if (!flagsStr) {
|
|
255
300
|
return r;
|
|
256
301
|
}
|
|
257
302
|
// parse groups like '-abc+xyz' or '+xyz-abc'
|
|
303
|
+
// When path is negated, swap the meaning of + and -
|
|
258
304
|
let i = 0;
|
|
259
305
|
while (i < flagsStr.length) {
|
|
260
306
|
const sign = flagsStr[i];
|
|
@@ -270,7 +316,16 @@ export class Right {
|
|
|
270
316
|
letters += flagsStr[i];
|
|
271
317
|
i++;
|
|
272
318
|
}
|
|
273
|
-
|
|
319
|
+
// When negated: + becomes deny, - becomes allow
|
|
320
|
+
let apply;
|
|
321
|
+
if (parsed.negated) {
|
|
322
|
+
apply =
|
|
323
|
+
sign === '+' ? (f) => r.deny(f) : (f) => r.allow(f);
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
apply =
|
|
327
|
+
sign === '+' ? (f) => r.allow(f) : (f) => r.deny(f);
|
|
328
|
+
}
|
|
274
329
|
if (letters === '*') {
|
|
275
330
|
apply(Flags.ALL);
|
|
276
331
|
continue;
|
package/dist/rights.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export type RightJSON = {
|
|
|
5
5
|
deny?: string;
|
|
6
6
|
description?: string;
|
|
7
7
|
path: string;
|
|
8
|
+
priority?: number;
|
|
8
9
|
tags?: string[];
|
|
9
10
|
validFrom?: string;
|
|
10
11
|
validUntil?: string;
|
|
@@ -24,6 +25,20 @@ export declare class Rights {
|
|
|
24
25
|
allRights(): Right[];
|
|
25
26
|
allow(path: string, ...flags: Flags[]): this;
|
|
26
27
|
deny(path: string, flag: Flags): this;
|
|
28
|
+
/**
|
|
29
|
+
* Exclude (deny) specific flags for a path pattern.
|
|
30
|
+
* This is equivalent to calling deny() for each flag but accepts multiple flags.
|
|
31
|
+
* Provides clearer semantics for exclusion patterns.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* rights.allow('/api/**', Flags.READ);
|
|
35
|
+
* rights.exclude('/api/internal/**', Flags.READ); // Deny read on internal paths
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* rights.allow('/files/**', Flags.ALL);
|
|
39
|
+
* rights.exclude('/files/system/**', Flags.DELETE, Flags.WRITE); // Protect system files
|
|
40
|
+
*/
|
|
41
|
+
exclude(path: string, ...flags: Flags[]): this;
|
|
27
42
|
private matchOrdered;
|
|
28
43
|
has(path: string, flag: Flags, context?: ConditionContext): boolean;
|
|
29
44
|
explain(path: string, flag: Flags, context?: ConditionContext): {
|
|
@@ -42,6 +57,10 @@ export declare class Rights {
|
|
|
42
57
|
delete(path: string, context?: ConditionContext): boolean;
|
|
43
58
|
create(path: string, context?: ConditionContext): boolean;
|
|
44
59
|
execute(path: string, context?: ConditionContext): boolean;
|
|
60
|
+
checkMany(requests: Array<{
|
|
61
|
+
flags: Flags;
|
|
62
|
+
path: string;
|
|
63
|
+
}>, context?: ConditionContext): boolean[];
|
|
45
64
|
toString(): string;
|
|
46
65
|
toJSON(): RightJSON[];
|
|
47
66
|
static fromJSON(arr: RightJSON[]): Rights;
|
package/dist/rights.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ALL_BITS, Flags, hasBit } from './constants';
|
|
2
|
+
import { normalizePath } from './helpers';
|
|
2
3
|
import { Right } from './right';
|
|
3
|
-
import { normalizePath } from './utils';
|
|
4
4
|
export class Rights {
|
|
5
5
|
constructor() {
|
|
6
6
|
this.list = [];
|
|
@@ -94,6 +94,40 @@ export class Rights {
|
|
|
94
94
|
this.notify();
|
|
95
95
|
return this;
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Exclude (deny) specific flags for a path pattern.
|
|
99
|
+
* This is equivalent to calling deny() for each flag but accepts multiple flags.
|
|
100
|
+
* Provides clearer semantics for exclusion patterns.
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* rights.allow('/api/**', Flags.READ);
|
|
104
|
+
* rights.exclude('/api/internal/**', Flags.READ); // Deny read on internal paths
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* rights.allow('/files/**', Flags.ALL);
|
|
108
|
+
* rights.exclude('/files/system/**', Flags.DELETE, Flags.WRITE); // Protect system files
|
|
109
|
+
*/
|
|
110
|
+
exclude(path, ...flags) {
|
|
111
|
+
// Strip leading ! if present (user might write exclude('!/path'))
|
|
112
|
+
const cleanPath = path.startsWith('!') ? path.slice(1) : path;
|
|
113
|
+
const p = normalizePath(cleanPath);
|
|
114
|
+
let r = this.list.find(x => x.path === p);
|
|
115
|
+
if (!r) {
|
|
116
|
+
r = new Right(p);
|
|
117
|
+
this.list.push(r);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
// Invalidate cache if we update an existing right
|
|
121
|
+
this.matchCache.clear();
|
|
122
|
+
}
|
|
123
|
+
// Support spreading an array: exclude(path, [Flags.READ, Flags.WRITE] as any)
|
|
124
|
+
const flat = [].concat(...flags);
|
|
125
|
+
for (const f of flat) {
|
|
126
|
+
r.deny(f);
|
|
127
|
+
}
|
|
128
|
+
this.notify();
|
|
129
|
+
return this;
|
|
130
|
+
}
|
|
97
131
|
matchOrdered(path) {
|
|
98
132
|
const cached = this.matchCache.get(path);
|
|
99
133
|
if (cached) {
|
|
@@ -101,7 +135,15 @@ export class Rights {
|
|
|
101
135
|
}
|
|
102
136
|
const result = this.list
|
|
103
137
|
.filter(r => r.matches(path))
|
|
104
|
-
.sort((a, b) =>
|
|
138
|
+
.sort((a, b) => {
|
|
139
|
+
// Priority first (higher wins)
|
|
140
|
+
const pDiff = b.priority - a.priority;
|
|
141
|
+
if (pDiff !== 0) {
|
|
142
|
+
return pDiff;
|
|
143
|
+
}
|
|
144
|
+
// Then specificity
|
|
145
|
+
return b.specificity() - a.specificity();
|
|
146
|
+
});
|
|
105
147
|
this.matchCache.set(path, result);
|
|
106
148
|
return result;
|
|
107
149
|
}
|
|
@@ -177,6 +219,9 @@ export class Rights {
|
|
|
177
219
|
execute(path, context) {
|
|
178
220
|
return this.has(path, Flags.EXECUTE, context);
|
|
179
221
|
}
|
|
222
|
+
checkMany(requests, context) {
|
|
223
|
+
return requests.map(req => this.has(req.path, req.flags, context));
|
|
224
|
+
}
|
|
180
225
|
toString() {
|
|
181
226
|
return this.list.map(r => r.toString()).join(', ');
|
|
182
227
|
}
|
|
@@ -189,6 +234,7 @@ export class Rights {
|
|
|
189
234
|
const p = normalizePath(item.path);
|
|
190
235
|
const init = {
|
|
191
236
|
description: item.description,
|
|
237
|
+
priority: item.priority,
|
|
192
238
|
tags: item.tags
|
|
193
239
|
};
|
|
194
240
|
if (item.validFrom) {
|