Skip to content

Commit

Permalink
feat(ability): adds support for manage action
Browse files Browse the repository at this point in the history
This action is an alias to any action, the prev `manage` is renamed to `crud`. Also fixed priority order for `all` subject name. Closes #119

BREAKING CHANGE: `manage` is not anymore an alias for CRUD but represents any action.

Let's consider the next example:

```js
const ability = AbilityBuilder.define((can) => {
  can('manage', 'Post')
  can('read', 'User')
})
```

In @casl/[email protected] the definition above produces the next results:

```js
ability.can('read', 'Post') // true
ability.can('publish', 'Post') // false, because `manage` is an alias for CRUD
```

In @casl/[email protected] the results:

```js
ability.can('read', 'Post') // true
ability.can('publish', 'Post') // true, because `manage` represents any action
```

To migrate the code, just replace `manage` with `crud` and everything will work as previously.
  • Loading branch information
thejuan authored and stalniy committed Feb 4, 2019
1 parent a509979 commit d9ab56c
Show file tree
Hide file tree
Showing 6 changed files with 109 additions and 34 deletions.
1 change: 1 addition & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
language: node_js
node_js:
- 6 # to be removed 2019-04-01
- 8 # 2019-12-01
- lts/*
- node

Expand Down
57 changes: 48 additions & 9 deletions packages/casl-ability/spec/ability.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ describe('Ability', () => {
expect(() => Ability.addAlias('sort', ['order', 'sort'])).to.throw(Error)
})

it('provides predefined to use "manage" alias for create, read, update, delete', () => {
ability = AbilityBuilder.define(can => can('manage', 'Post'))
it('provides predefined to use "crud" alias for create, read, update, delete', () => {
ability = AbilityBuilder.define(can => can('crud', 'Post'))

expect(ability).to.allow('manage', 'Post')
expect(ability).to.allow('crud', 'Post')
expect(ability).to.allow('create', 'Post')
expect(ability).to.allow('read', 'Post')
expect(ability).to.allow('update', 'Post')
Expand All @@ -44,15 +44,15 @@ describe('Ability', () => {

it('lists all rules', () => {
ability = AbilityBuilder.define((can, cannot) => {
can('manage', 'all')
can('crud', 'all')
can('learn', 'Range')
cannot('read', 'String')
cannot('read', 'Hash')
cannot('preview', 'Array')
})

expect(ability.rules).to.deep.equal([
{ actions: 'manage', subject: ['all'] },
{ actions: 'crud', subject: ['all'] },
{ actions: 'learn', subject: ['Range'] },
{ actions: 'read', subject: ['String'], inverted: true },
{ actions: 'read', subject: ['Hash'], inverted: true },
Expand Down Expand Up @@ -234,7 +234,7 @@ describe('Ability', () => {

it('shadows rule with conditions by the same rule without conditions', () => {
ability = AbilityBuilder.define(can => {
can('manage', 'Post')
can('crud', 'Post')
can('delete', 'Post', { creator: 'me' })
})

Expand All @@ -244,7 +244,7 @@ describe('Ability', () => {

it('does not shadow rule with conditions by the same rule if the last one is disallowed by `cannot`', () => {
ability = AbilityBuilder.define((can, cannot) => {
can('manage', 'Post')
can('crud', 'Post')
cannot('delete', 'Post')
can('delete', 'Post', { creator: 'me' })
})
Expand All @@ -256,13 +256,13 @@ describe('Ability', () => {
it('shadows inverted rule by regular one', () => {
ability = AbilityBuilder.define((can, cannot) => {
cannot('delete', 'Post', { creator: 'me' })
can('manage', 'Post', { creator: 'me' })
can('crud', 'Post', { creator: 'me' })
})

expect(ability).to.allow('delete', new Post({ creator: 'me' }))
})

it('favor subject specific rules over general ones (i.e., defined via "all")', () => {
it('shadows `all` subject rule by specific one', () => {
ability = AbilityBuilder.define((can, cannot) => {
can('delete', 'all')
cannot('delete', 'Post')
Expand Down Expand Up @@ -460,6 +460,45 @@ describe('Ability', () => {
})
})

describe('`manage` action', () => {
it('is an alias for any action', () => {
ability = AbilityBuilder.define((can, cannot) => {
can('manage', 'all')
})

expect(ability).to.allow('read', 'post')
expect(ability).to.allow('do_whatever_anywhere', 'post')
})

it('honours `cannot` rules', () => {
ability = AbilityBuilder.define((can, cannot) => {
can('manage', 'all')
cannot('read', 'post')
})

expect(ability).not.to.allow('read', 'post')
expect(ability).to.allow('update', 'post')
})

it('can be used with `cannot`', () => {
ability = AbilityBuilder.define((can, cannot) => {
can('read', 'post')
cannot('manage', 'all')
})

expect(ability).not.to.allow('read', 'post')
expect(ability).not.to.allow('delete', 'post')
})

it('honours field specific rules', () => {
ability = AbilityBuilder.define((can, cannot) => {
can('manage', 'all', 'subject')
})

expect(ability).to.allow('read', 'post', 'subject')
})
});

describe('`rulesFor`', () => {
it('returns rules for specific subject and action', () => {
ability = AbilityBuilder.define((can, cannot) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/casl-ability/spec/pack_rules.spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { packRules, unpackRules } from '../src/extra'

fdescribe('Ability rules packing', () => {
describe('Ability rules packing', () => {
describe('`packRules` function', () => {
it('converts array of rule objects to array of rule arrays', () => {
const rules = packRules([
Expand Down
51 changes: 34 additions & 17 deletions packages/casl-ability/src/ability.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import { ForbiddenError } from './error';
import { Rule } from './rule';
import { wrapArray, getSubjectName } from './utils';

function clone(object) {
return JSON.parse(JSON.stringify(object));
}
import { wrapArray, getSubjectName, clone } from './utils';

const PRIVATE_FIELD = typeof Symbol !== 'undefined' ? Symbol('private') : `__${Date.now()}`;
const DEFAULT_ALIASES = {
manage: ['create', 'read', 'update', 'delete'],
crud: ['create', 'read', 'update', 'delete'],
};
const PRIVATE_FIELD = typeof Symbol !== 'undefined' ? Symbol('private') : `__${Date.now()}`;

export class Ability {
static addAlias(alias, actions) {
Expand All @@ -26,7 +22,8 @@ export class Ability {
RuleType,
subjectName,
originalRules: rules || [],
rules: {},
indexedRules: Object.create(null),
mergedRules: Object.create(null),
events: {},
aliases: clone(DEFAULT_ALIASES)
};
Expand All @@ -39,30 +36,32 @@ export class Ability {

this.emit('update', payload);
this[PRIVATE_FIELD].originalRules = Object.freeze(rules.slice(0));
this[PRIVATE_FIELD].rules = this.buildIndexFor(this.rules);
this[PRIVATE_FIELD].indexedRules = this.buildIndexFor(rules);
this[PRIVATE_FIELD].mergedRules = Object.create(null);
this.emit('updated', payload);
}

return this;
}

buildIndexFor(rules) {
const indexedRules = {};
const indexedRules = Object.create(null);
const { RuleType } = this[PRIVATE_FIELD];

for (let i = 0; i < rules.length; i++) {
const rule = new RuleType(rules[i]);
const actions = this.expandActions(rule.actions);
const subjects = wrapArray(rule.subject);
const priority = rules.length - i - 1;

for (let k = 0; k < subjects.length; k++) {
const subject = subjects[k];
indexedRules[subject] = indexedRules[subject] || {};
indexedRules[subject] = indexedRules[subject] || Object.create(null);

for (let j = 0; j < actions.length; j++) {
const action = actions[j];
indexedRules[subject][action] = indexedRules[subject][action] || [];
indexedRules[subject][action].unshift(rule);
indexedRules[subject][action] = indexedRules[subject][action] || Object.create(null);
indexedRules[subject][action][priority] = rule;
}
}
}
Expand Down Expand Up @@ -110,11 +109,29 @@ export class Ability {

possibleRulesFor(action, subject) {
const subjectName = this[PRIVATE_FIELD].subjectName(subject);
const { rules } = this[PRIVATE_FIELD];
const specificRules = rules.hasOwnProperty(subjectName) ? rules[subjectName][action] : null;
const generalRules = rules.hasOwnProperty('all') ? rules.all[action] : null;
const { mergedRules } = this[PRIVATE_FIELD];
const key = `${subjectName}_${action}`;

if (!mergedRules[key]) {
mergedRules[key] = this.mergeRulesFor(action, subjectName);
}

return mergedRules[key];
}

mergeRulesFor(action, subjectName) {
const { indexedRules } = this[PRIVATE_FIELD];
const mergedRules = [subjectName, 'all'].reduce((rules, subjectType) => {
const subjectRules = indexedRules[subjectType];

if (!subjectRules) {
return rules;
}

return Object.assign(rules, subjectRules[action], subjectRules.manage);
}, []);

return (specificRules || []).concat(generalRules || []);
return mergedRules.filter(Boolean);
}

rulesFor(action, subject, field) {
Expand Down
4 changes: 4 additions & 0 deletions packages/casl-ability/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,7 @@ export function getSubjectName(subject) {

return Type.modelName || Type.name;
}

export function clone(object) {
return JSON.parse(JSON.stringify(object));
}
28 changes: 21 additions & 7 deletions packages/casl-angular/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit d9ab56c

Please sign in to comment.