Skip to content
This repository was archived by the owner on Dec 12, 2020. It is now read-only.

Commit 321d013

Browse files
authored
Merge pull request #52 from myovchev/feat/custom-conditions-docs
Add custom conditions use cases to the docs
2 parents 3da11e6 + 68c4445 commit 321d013

File tree

4 files changed

+430
-3
lines changed

4 files changed

+430
-3
lines changed

README.md

+215-3
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ e.g. `ac.can(role).execute('create').on(resource)`
2020
- Grant permissions by attributes defined by glob notation (with nested object support).
2121
- Ability to filter data (model) instance by allowed attributes.
2222
- Ability to control access using conditions.
23-
- Supports AND, OR, NOT, EQUALS, NOT_EQUALS, STARTS_WITH, LIST_CONTAINS conditions.
24-
- You can specify dynamic context values in the conditions using JSON Paths.
25-
- You can define your own condition functions too but please note if you use custom functions instead of standard conditions, you won't be able to save them as json in the DB.
23+
- Supports AND, OR, NOT, EQUALS, NOT_EQUALS, STARTS_WITH, LIST_CONTAINS core conditions.
24+
- You can specify dynamic context values in the core conditions using JSON Paths.
25+
- Supports your own custom conditions e.g. `custom:isArticleOwner`.
26+
- You can define your own function conditions too but please note if you use custom functions instead of standard conditions, you won't be able to save them as json in the DB.
2627
- Policies are JSON compatible so can be stored and retrieved from database.
2728
- Fast. (Grants are stored in memory, no database queries.)
2829
- TypeScript support.
@@ -123,6 +124,217 @@ permission = await ac.can('user').context({ category: 'sports' }).execute('creat
123124
console.log(permission.granted); // —> true
124125
```
125126

127+
### Custom Conditions
128+
129+
You can declare your own conditions (**requires version >= 4.5.2**). Those declarations should be registerd with the library BEFORE your grants and permission checks. The custom condition declarations are allowing you to extend the library core conditions with your own business logic without sacrificing the abillity to serialize your grants.
130+
131+
**Basic example:**
132+
```js
133+
// 1. Define the condition handler
134+
const greaterOrEqual = (context, args) => {
135+
if (!args || typeof args.level !== 'number') {
136+
throw new Error('custom:gte requires "level" argument');
137+
}
138+
139+
return +context.level >= args.level;
140+
}
141+
142+
const ac = new AccessControl();
143+
144+
// 2. Register the condition with appropriate name
145+
ac.registerConditionFunction('gte', greaterOrEqual);
146+
147+
// 3. Use it in grants, same as core conditions but with "custom:" prefix
148+
ac.grant('user')
149+
.condition({
150+
Fn: 'custom:gte',
151+
args: { level: 2 }
152+
})
153+
.execute('comment').on('article');
154+
155+
// 4. Evaluate permissions with appropraite context (sync) - same as core conditions
156+
const permission1 = ac
157+
.can('user')
158+
.context({ level: 2 })
159+
.execute('comment')
160+
.sync()
161+
.on('article');
162+
163+
// prints "LEVEL 2 true"
164+
console.log('LEVEL 2', permission1.granted);
165+
166+
const permission2 = ac
167+
.can('user')
168+
.context({ level: 1 })
169+
.execute('comment')
170+
.sync()
171+
.on('article');
172+
173+
// prints "LEVEL 1 false"
174+
console.log('LEVEL 1', permission2.granted);
175+
```
176+
177+
**Argument is optional:**
178+
179+
Custom condition argument is optional - same as core conditions.
180+
181+
```js
182+
const myConditions = {
183+
isArticleOwner: (context) => {
184+
return context.loginUserId && context.loginUserId === context.articleOwnerId
185+
}
186+
}
187+
const ac = new AccessControl();
188+
ac.registerConditionFunction('isArticleOwner', myConditions.isArticleOwner);
189+
ac.grant("user").condition('custom:isArticleOwner')
190+
.execute(['delete', 'update']).on('article');
191+
192+
ac.can('user').context({ loginUserId: 1, articleOwnerId: 1 })
193+
.execute('update').sync().on('article');
194+
// { granted: true }
195+
```
196+
197+
**Custom condition can be async:**
198+
```js
199+
import { asyncCheckResourceForUser } from './somewhere';
200+
201+
const myConditions = {
202+
isResourceOwner: (context, args) => {
203+
const { resource } = args || {};
204+
const { loginUserId } = context;
205+
// your business logic to check resource owner e.g. vs DB
206+
// send resource name, currently logged in user ID, record.id
207+
return asyncCheckResourceForUser(resource, loginUserId, context[resource]);
208+
}
209+
}
210+
const ac = new AccessControl();
211+
ac.registerConditionFunction('isResourceOwner', myConditions.isResourceOwner);
212+
213+
ac.grant("user")
214+
.condition({ Fn: 'custom:isResourceOwner', args: { resource: 'article' } })
215+
.execute(['delete', 'update'])
216+
.on('article');
217+
218+
// Provide currently logged in user and article.id in the context
219+
await ac.can('user').context({ loginUserId: 1, article: { id: 10 } })
220+
.execute('update').on('article');
221+
```
222+
223+
**Custom conditions allow security policy serializing and can be registered while initializing (in batch):**
224+
225+
> NOTE: function conditions are not serializeable, so custom conditions are the recommended way to implement your permission policy. You can easiely convert your current function conditions to custom conditions.
226+
227+
```js
228+
const myPolicy = {
229+
// Serialized policy, can be stored in file, DB, etc
230+
grants: [
231+
{
232+
role: 'user',
233+
resource: 'profile',
234+
action: ['delete', 'update'],
235+
attributes: ['*'],
236+
condition: {
237+
Fn: 'custom:isResourceOwner',
238+
args: { resource: 'profile' }
239+
}
240+
},
241+
{
242+
role: 'user',
243+
resource: 'article',
244+
action: ['delete', 'update'],
245+
attributes: ['*'],
246+
condition: {
247+
Fn: 'custom:isResourceOwner',
248+
args: { resource: 'article' }
249+
}
250+
},
251+
],
252+
// Map your custom conditions to the serialized policy
253+
myConditions: {
254+
isResourceOwner: async ({ user, record }, { resource } = {}) => {
255+
// Your business logic here, e.g. query database...
256+
if (resource === 'profile' && user.id === 1 && record.id === 1) {
257+
return true;
258+
}
259+
if (resource === 'article' && user.id === 1 && record.id === 2) {
260+
return true;
261+
}
262+
return false;
263+
}
264+
}
265+
};
266+
267+
// Register everything on initialization
268+
const ac = new AccessControl(myPolicy.grants, myPolicy.myConditions);
269+
270+
// Use it
271+
await ac.can('user').context({ user: { id: 1 }, record: { id: 1 } })
272+
.execute('update').on('profile'); // { granted: true }
273+
274+
await ac.can('user').context({ user: { id: 1 }, record: { id: 1 } })
275+
.execute('delete').on('article'); // { granted: false }
276+
277+
await ac.can('user').context({ user: { id: 1 }, record: { id: 2 } })
278+
.execute('delete').on('article'); // { granted: true }
279+
```
280+
281+
**Mix with core conditions, use JSON path helper:**
282+
283+
> NOTE: `getValueByPath` is available in versions >= 4.5.5
284+
285+
```js
286+
const myPolicy = {
287+
grants: [
288+
{
289+
role: 'editor/news',
290+
resource: 'article',
291+
action: 'approve',
292+
attributes: ['*'],
293+
// Mix core with custom conditions
294+
condition: {
295+
Fn: 'AND',
296+
args: [
297+
{
298+
Fn: 'custom:categoryMatcher',
299+
args: { type: 'news' }
300+
},
301+
{
302+
Fn: 'custom:isResourceOwner',
303+
args: { resource: 'article' }
304+
}
305+
]
306+
}
307+
},
308+
],
309+
myConditions: {
310+
categoryMatcher: (context, { type } = {}) => {
311+
// A naive use of the JSON path util
312+
// Keep in mind it comes with performance penalties
313+
return type && getValueByPath(context, '$.category.type') === type;
314+
},
315+
isResourceOwner: (context, { resource } = {}) => {
316+
if (!resource) {
317+
return false;
318+
}
319+
return getValueByPath(context, `$.${resource}.owner`) === getValueByPath(context, '$.user.id');
320+
},
321+
}
322+
};
323+
const ac = new AccessControl(myPolicy.grants, myPolicy.myConditions);
324+
325+
// Evaluate with article.owner equals to user.id, category.type equals to 'news'
326+
await ac.can('editor/news').context({ user: { id: 1 }, article: { owner: 1 }, category: { type: 'news' } })
327+
.execute('approve').on('article'); // { granted: true }
328+
329+
// Evaluate with article.owner DOESN'T equal to user.id, category.type equals to 'news'
330+
await ac.can('editor/news').context({ user: { id: 1 }, article: { owner: 2 }, category: { type: 'news' } })
331+
.execute('approve').on('article'); // { granted: false }
332+
333+
// Evaluate with article.owner equals to user.id, category.type DOESN'T equal to 'news'
334+
await ac.can('editor/news').context({ user: { id: 1 }, article: { owner: 1 }, category: { type: 'tutorials' } })
335+
.execute('approve').on('article'); // { granted: false }
336+
```
337+
126338
### Wildcard (glob notation) Resource and Actions Examples
127339
```js
128340
ac.grant({

src/conditions/util.ts

+3
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,6 @@ export class ConditionUtil {
160160
return valuePathOrValue;
161161
}
162162
}
163+
164+
// Expose getValueByPath only as public API
165+
export const getValueByPath = ConditionUtil.getValueByPath;

src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './AccessControl';
22
export * from './core';
3+
export { getValueByPath } from './conditions/util';

0 commit comments

Comments
 (0)