Skip to content

Commit

Permalink
feat(ability): implement per field rules support
Browse files Browse the repository at this point in the history
Relates #18
  • Loading branch information
stalniy committed Mar 21, 2018
1 parent 91d2cb1 commit 239df75
Show file tree
Hide file tree
Showing 6 changed files with 218 additions and 60 deletions.
126 changes: 126 additions & 0 deletions packages/casl-ability/spec/ability.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -367,4 +367,130 @@ describe('Ability', () => {
expect(ability).to.allow('delete', new Post({ title: '[DELETED] title' }))
})
})

describe('per field abilities', () => {
it('allows to define per field rules', () => {
ability = AbilityBuilder.define(can => can('read', 'Post', 'title'))

expect(ability).to.allow('read', 'Post')
expect(ability).to.allow('read', 'Post', 'title')
expect(ability).not.to.allow('read', 'Post', 'description')
})

it('allows to define rules for several fields', () => {
ability = AbilityBuilder.define(can => can('read', 'Post', ['title', 'id']))

expect(ability).to.allow('read', 'Post')
expect(ability).to.allow('read', 'Post', 'title')
expect(ability).to.allow('read', 'Post', 'id')
expect(ability).not.to.allow('read', 'Post', 'description')
})

it('allows to define inverted rules for a field', () => {
ability = AbilityBuilder.define((can, cannot) => {
can('read', 'Post')
cannot('read', 'Post', 'description')
})

expect(ability).to.allow('read', 'Post')
expect(ability).to.allow('read', 'Post', 'title')
expect(ability).not.to.allow('read', 'Post', 'description')
})

it('allows to perform actions on all attributes if none is specified', () => {
ability = AbilityBuilder.define(can => can('read', 'Post'))

expect(ability).to.allow('read', 'Post', 'title')
expect(ability).to.allow('read', 'Post', 'description')
})

describe('when `conditions` defined', () => {
const myPost = new Post({ author: 'me' })

beforeEach(() => {
ability = AbilityBuilder.define(can => {
can('read', 'Post', ['title', 'description'], { author: myPost.author })
})
})

it('allows to perform action on subject specified as string', () => {
expect(ability).to.allow('read', 'Post')
})

it('allows to perform action on subject field, both specified as strings', () => {
expect(ability).to.allow('read', 'Post', 'title')
expect(ability).to.allow('read', 'Post', 'description')
})

it('does not allow to perform action on instance of the subject which mismatches specified conditions', () => {
expect(ability).not.to.allow('read', new Post())
})

it('allows to perform action on instance which matches conditions', () => {
expect(ability).to.allow('read', myPost)
})

it('allows to perform action on instance field if that instance matches conditions', () => {
expect(ability).to.allow('read', myPost, 'title')
expect(ability).to.allow('read', myPost, 'description')
})

it('does not allow to perform action on instance field if that instance matches conditions but field is not in specified list', () => {
expect(ability).not.to.allow('read', myPost, 'id')
})
})
})

describe('`rulesFor`', () => {
it('returns rules for specific subject and action', () => {
ability = AbilityBuilder.define((can, cannot) => {
can('read', 'Post')
can('update', 'Post')
cannot('read', 'Post', { private: true })
})

const rules = ability.rulesFor('read', 'Post').map(ruleToObject)

expect(rules).to.deep.equal([
{ actions: 'read', subject: 'Post', inverted: true, conditions: { private: true } },
{ actions: 'read', subject: 'Post', inverted: false },
])
})

it('does not return inverted rules with fields when invoked for specific subject and action', () => {
ability = AbilityBuilder.define((can, cannot) => {
can('read', 'Post')
cannot('read', 'Post', 'title')
})

const rules = ability.rulesFor('read', 'Post').map(ruleToObject)

expect(rules).to.deep.equal([
{ actions: 'read', subject: 'Post', inverted: false },
])
})

it('returns rules for specific subject, action and field', () => {
ability = AbilityBuilder.define((can, cannot) => {
can('read', 'Post')
cannot('read', 'Post', 'title')
})

const rules = ability.rulesFor('read', 'Post', 'title').map(ruleToObject)

expect(rules).to.deep.equal([
{ actions: 'read', subject: 'Post', inverted: true, fields: ['title'] },
{ actions: 'read', subject: 'Post', inverted: false }
])
})

function ruleToObject(rule) {
return ['actions', 'subject', 'conditions', 'fields', 'inverted'].reduce((object, field) => {
if (typeof rule[field] !== 'undefined') {
object[field] = rule[field]
}
return object
}, {})
}
})
})
83 changes: 47 additions & 36 deletions packages/casl-ability/spec/query.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,86 +9,97 @@ function toQuery(rules) {
}

describe('rulesToQuery', () => {
const { can, cannot } = AbilityBuilder.extract()

it('is empty if there are no rules with conditions', () => {
const query = toQuery([can('read', 'Post')])
it('returns empty object if there are no rules with conditions', () => {
const ability = AbilityBuilder.define(can => can('read', 'Post'))
const query = toQuery(ability.rulesFor('read', 'Post'))

expect(Object.keys(query)).to.be.empty
})

it('has empty `$or` part if at least one regular rule does not have conditions', () => {
const query = toQuery([
can('read', 'Post', { author: 123 }),
it('returns `null` if empty list rules is passed', () => {
const query = toQuery([])

expect(query).to.be.null
})

it('returns empty `$or` part if at least one regular rule does not have conditions', () => {
const ability = AbilityBuilder.define(can => {
can('read', 'Post', { author: 123 })
can('read', 'Post')
])
})
const query = toQuery(ability.rulesFor('read', 'Post'))

expect(Object.keys(query)).to.be.empty
})

it('equals `null` if at least one inverted rule does not have conditions', () => {
const query = toQuery([
cannot('read', 'Post', { author: 123 }),
it('returns `null` if at least one inverted rule does not have conditions', () => {
const ability = AbilityBuilder.define((can, cannot) => {
cannot('read', 'Post', { author: 123 })
cannot('read', 'Post')
])
})
const query = toQuery(ability.rulesFor('read', 'Post'))

expect(query).to.be.null
})

it('equals `null` if at least one inverted rule does not have conditions even if direct condition exists', () => {
const query = toQuery([
can('read', 'Post', { public: true }),
cannot('read', 'Post', { author: 321 }),
it('returns `null` if at least one inverted rule does not have conditions even if direct condition exists', () => {
const ability = AbilityBuilder.define((can, cannot) => {
can('read', 'Post', { public: true })
cannot('read', 'Post', { author: 321 })
cannot('read', 'Post')
])
})
const query = toQuery(ability.rulesFor('read', 'Post'))

expect(query).to.be.null
})

it('OR-es conditions for regular rules', () => {
const query = toQuery([
can('read', 'Post', { status: 'draft', createdBy: 'someoneelse' }),
const ability = AbilityBuilder.define(can => {
can('read', 'Post', { status: 'draft', createdBy: 'someoneelse' })
can('read', 'Post', { status: 'published', createdBy: 'me' })
])
})
const query = toQuery(ability.rulesFor('read', 'Post'))

expect(query).to.deep.equal({
$or: [
{ status: 'draft', createdBy: 'someoneelse' },
{ status: 'published', createdBy: 'me' }
{ status: 'published', createdBy: 'me' },
{ status: 'draft', createdBy: 'someoneelse' }
]
})
})

it('AND-es conditions for inverted rules', () => {
const query = toQuery([
cannot('read', 'Post', { status: 'draft', createdBy: 'someoneelse' }),
const ability = AbilityBuilder.define((can, cannot) => {
cannot('read', 'Post', { status: 'draft', createdBy: 'someoneelse' })
cannot('read', 'Post', { status: 'published', createdBy: 'me' })
])
})
const query = toQuery(ability.rulesFor('read', 'Post'))

expect(query).to.deep.equal({
$and: [
{ $not: { status: 'draft', createdBy: 'someoneelse' } },
{ $not: { status: 'published', createdBy: 'me' } }
{ $not: { status: 'published', createdBy: 'me' } },
{ $not: { status: 'draft', createdBy: 'someoneelse' } }
]
})
})

it('OR-es conditions for regular rules and AND-es for inverted ones', () => {
const query = toQuery([
can('read', 'Post', { _id: 'mega' }),
can('read', 'Post', { state: 'draft' }),
cannot('read', 'Post', { private: true }),
const ability = AbilityBuilder.define((can, cannot) => {
can('read', 'Post', { _id: 'mega' })
can('read', 'Post', { state: 'draft' })
cannot('read', 'Post', { private: true })
cannot('read', 'Post', { state: 'archived' })
])
})
const query = toQuery(ability.rulesFor('read', 'Post'))

expect(query).to.deep.equal({
$or: [
{ _id: 'mega' },
{ state: 'draft' }
{ state: 'draft' },
{ _id: 'mega' }
],
$and: [
{ $not: { private: true } },
{ $not: { state: 'archived' } }
{ $not: { state: 'archived' } },
{ $not: { private: true } }
]
})
})
Expand Down
4 changes: 2 additions & 2 deletions packages/casl-ability/spec/spec_helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
factory(window.chai, window);
}
})(function(chai) {
chai.Assertion.addMethod('allow', function(action, subject) {
chai.Assertion.addMethod('allow', function(action, subject, field) {
const subjectRepresantation = prettifyObject(subject)
this.assert(
this._obj.can(action, subject),
this._obj.can(action, subject, field),
`expected ability to allow ${action} on ${subjectRepresantation}`,
`expected ability to not allow ${action} on ${subjectRepresantation}`
);
Expand Down
20 changes: 8 additions & 12 deletions packages/casl-ability/src/ability.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export class Ability {
this[PRIVATE_FIELD] = {
RuleType,
subjectName,
originalRules: rules,
originalRules: rules || [],
rules: {},
events: {},
aliases: clone(DEFAULT_ALIASES)
Expand Down Expand Up @@ -96,13 +96,8 @@ export class Ability {
return this[PRIVATE_FIELD].originalRules;
}

can(action, subject) {
const subjectName = this[PRIVATE_FIELD].subjectName(subject);
const rules = this.rulesFor(action, subject);

if (subject === subjectName) {
return rules.length > 0 && !rules[0].inverted;
}
can(action, subject, field) {
const rules = this.rulesFor(action, subject, field);

for (let i = 0; i < rules.length; i++) {
if (rules[i].matches(subject)) {
Expand All @@ -113,17 +108,18 @@ export class Ability {
return false;
}

rulesFor(action, subject) {
rulesFor(action, subject, field) {
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 relevantRules = (specificRules || []).concat(generalRules || []);

return (specificRules || []).concat(generalRules || []);
return relevantRules.filter(rule => rule.matchesField(subject, field));
}

cannot(action, subject) {
return !this.can(action, subject);
cannot(...args) {
return !this.can(...args);
}

throwUnlessCan(action, subject) {
Expand Down
14 changes: 11 additions & 3 deletions packages/casl-ability/src/builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ function isStringOrNonEmptyArray(value) {
return typeof value === 'string' || Array.isArray(value) && value.length > 0;
}

function isObject(value) {
return value && typeof value === 'object';
}

export class AbilityBuilder {
static define(params, dsl) {
const options = typeof params === 'function' ? {} : params;
Expand All @@ -28,7 +32,7 @@ export class AbilityBuilder {
this.rules = [];
}

can(actions, subject, conditions) {
can(actions, subject, conditionsOrFields, conditions) {
if (!isStringOrNonEmptyArray(actions)) {
throw new TypeError('AbilityBuilder#can expects the first parameter to be an action or array of actions');
}
Expand All @@ -39,8 +43,12 @@ export class AbilityBuilder {

const rule = { actions, subject };

if (typeof conditions === 'object' && conditions) {
rule.conditions = conditions;
if (Array.isArray(conditionsOrFields) || typeof conditionsOrFields === 'string') {
rule.fields = conditionsOrFields;
}

if (isObject(conditions) || !rule.fields && isObject(conditionsOrFields)) {
rule.conditions = conditions || conditionsOrFields;
}

this.rules.push(rule);
Expand Down
Loading

0 comments on commit 239df75

Please sign in to comment.