From 6d931680dd9b6e2aa9d772868bbce18619d4236b Mon Sep 17 00:00:00 2001 From: Sergii Stotskyi Date: Wed, 21 Mar 2018 07:50:54 +0200 Subject: [PATCH] feat(extra): adds `permittedAttributesOf` This function allows to extract permitted fields from Ability rules Relates to #18 --- packages/casl-ability/package.json | 6 +- packages/casl-ability/spec/ability.spec.js | 8 +- .../spec/permitted_fields.spec.js | 73 +++++++++++++++++++ packages/casl-ability/spec/query.spec.js | 3 +- packages/casl-ability/spec/spec_helper.js | 54 +++++++------- packages/casl-ability/src/ability.js | 10 ++- .../casl-ability/src/{query.js => extra.js} | 18 +++++ packages/casl-ability/src/rule.js | 2 +- 8 files changed, 131 insertions(+), 43 deletions(-) create mode 100644 packages/casl-ability/spec/permitted_fields.spec.js rename packages/casl-ability/src/{query.js => extra.js} (52%) diff --git a/packages/casl-ability/package.json b/packages/casl-ability/package.json index f8c3e98fc..5a79c10bc 100644 --- a/packages/casl-ability/package.json +++ b/packages/casl-ability/package.json @@ -17,9 +17,9 @@ "build.es": "rollup -c ../../tools/rollup.es.js -e sift", "build.umd": "rollup -c ../../tools/rollup.umd.js -e sift -n casl", "build.es5m": "rollup -c ../../tools/rollup.es5m.js -e sift", - "build.extra.es": "rollup -c ../../tools/rollup.es.js -i src/query.js -o dist/es6/extra.js", - "build.extra.es5m": "rollup -c ../../tools/rollup.es5m.js -i src/query.js -o extra.js", - "build.extra.umd": "rollup -c ../../tools/rollup.umd.js -i src/query.js -o dist/umd/extra.js -n casl.extra", + "build.extra.es": "rollup -c ../../tools/rollup.es.js -i src/extra.js -o dist/es6/extra.js", + "build.extra.es5m": "rollup -c ../../tools/rollup.es5m.js -i src/extra.js -o extra.js", + "build.extra.umd": "rollup -c ../../tools/rollup.umd.js -i src/extra.js -o dist/umd/extra.js -n casl.extra", "build.extra": "npm run build.extra.es && npm run build.extra.es5m && npm run build.extra.umd", "build": "npm run build.es && npm run build.umd && npm run build.es5m && npm run build.extra" }, diff --git a/packages/casl-ability/spec/ability.spec.js b/packages/casl-ability/spec/ability.spec.js index 4c8507f0d..f5ef90743 100755 --- a/packages/casl-ability/spec/ability.spec.js +++ b/packages/casl-ability/spec/ability.spec.js @@ -1,11 +1,5 @@ import { AbilityBuilder, ForbiddenError, Ability } from '../src' -import './spec_helper' - -class Post { - constructor(attrs) { - Object.assign(this, attrs) - } -} +import { Post } from './spec_helper' describe('Ability', () => { let ability diff --git a/packages/casl-ability/spec/permitted_fields.spec.js b/packages/casl-ability/spec/permitted_fields.spec.js new file mode 100644 index 000000000..43e77c242 --- /dev/null +++ b/packages/casl-ability/spec/permitted_fields.spec.js @@ -0,0 +1,73 @@ +import { AbilityBuilder } from '../src' +import { permittedFieldsOf } from '../src/extra' +import { Post } from './spec_helper' + +describe('permittedFieldsOf', () => { + it('returns an empty array for `Ability` with empty rules', () => { + const ability = AbilityBuilder.define(() => {}) + expect(permittedFieldsOf(ability, 'read', 'Post')).to.be.empty + }) + + it('returns an empty array if none of rules contain fields', () => { + const ability = AbilityBuilder.define(can => can('read', 'Post')) + + expect(permittedFieldsOf(ability, 'read', 'Post')).to.be.empty + }) + + it('returns a unique array of fields if there are duplicated fields across fields', () => { + const ability = AbilityBuilder.define(can => { + can('read', 'Post', ['title']) + can('read', 'Post', ['title', 'description'], { id: 1 }) + }) + const fields = permittedFieldsOf(ability, 'read', 'Post') + + expect(fields).to.have.length(2) + expect(fields).to.have.all.members(['title', 'description']) + }) + + it('returns unique fields for array which contains direct and inverted rules', () => { + const ability = AbilityBuilder.define((can, cannot) => { + can('read', 'Post', ['title', 'description']) + cannot('read', 'Post', ['description']) + }) + const fields = permittedFieldsOf(ability, 'read', 'Post') + + expect(fields).to.have.length(1) + expect(fields).to.have.all.members(['title']) + }) + + it('allows to provide an option `fieldsFrom` which extract fields from rule', () => { + const ability = AbilityBuilder.define(can => can('read', 'Post')) + const fields = permittedFieldsOf(ability, 'read', 'Post', { + fieldsFrom: rule => rule.fields || ['title'] + }) + + expect(fields).to.deep.equal(['title']) + }) + + describe('when `subject` is an instance', () => { + let ability + + beforeEach(() => { + ability = AbilityBuilder.define((can, cannot) => { + can('read', 'Post', ['title']) + can('read', 'Post', ['title', 'description'], { id: 1 }) + cannot('read', 'Post', ['description'], { private: true }) + }) + }) + + it('allows to return fields for specific instance', () => { + const post = new Post({ title: 'does not match conditions' }) + const fields = permittedFieldsOf(ability, 'read', post) + + expect(fields).to.deep.equal(['title']) + }) + + it('allows to return fields for subject instance which matches specified rule conditions', () => { + const post = new Post({ id: 1, title: 'matches conditions' }) + const fields = permittedFieldsOf(ability, 'read', post) + + expect(fields).to.deep.equal(['title', 'description']) + }) + }) +}) diff --git a/packages/casl-ability/spec/query.spec.js b/packages/casl-ability/spec/query.spec.js index 71d0a3f3d..dd473c204 100644 --- a/packages/casl-ability/spec/query.spec.js +++ b/packages/casl-ability/spec/query.spec.js @@ -1,6 +1,5 @@ import { AbilityBuilder } from '../src' -import { rulesToQuery } from '../src/query' -import './spec_helper' +import { rulesToQuery } from '../src/extra' function toQuery(rules) { return rulesToQuery(rules, rule => { diff --git a/packages/casl-ability/spec/spec_helper.js b/packages/casl-ability/spec/spec_helper.js index a25941777..dbd19d749 100755 --- a/packages/casl-ability/spec/spec_helper.js +++ b/packages/casl-ability/spec/spec_helper.js @@ -1,32 +1,32 @@ -(function(factory) { - if (typeof require === 'function' && typeof module !== 'undefined') { - require('chai').use(require('chai-spies')); - factory(require('chai'), global); - } else if (typeof window === 'object') { - window.global = window; - factory(window.chai, window); - } -})(function(chai) { - chai.Assertion.addMethod('allow', function(action, subject, field) { - const subjectRepresantation = prettifyObject(subject) - this.assert( - this._obj.can(action, subject, field), - `expected ability to allow ${action} on ${subjectRepresantation}`, - `expected ability to not allow ${action} on ${subjectRepresantation}` - ); - }); +import chai from 'chai' +import spies from 'chai-spies' - function prettifyObject(object) { - if (!object || typeof object === 'string') { - return object; - } +chai.use(spies) - if (typeof object === 'function') { - return object.name; - } +chai.Assertion.addMethod('allow', function(action, subject, field) { + const subjectRepresantation = prettifyObject(subject) + this.assert( + this._obj.can(action, subject, field), + `expected ability to allow ${action} on ${subjectRepresantation}`, + `expected ability to not allow ${action} on ${subjectRepresantation}` + ); +}); - const attrs = JSON.stringify(object); - return `${object.constructor.name} { ${attrs[0] === '{' ? attrs.slice(1, -1) : attrs} }` +function prettifyObject(object) { + if (!object || typeof object === 'string') { + return object; } -}); + if (typeof object === 'function') { + return object.name; + } + + const attrs = JSON.stringify(object); + return `${object.constructor.name} { ${attrs[0] === '{' ? attrs.slice(1, -1) : attrs} }` +} + +export class Post { + constructor(attrs) { + Object.assign(this, attrs) + } +} diff --git a/packages/casl-ability/src/ability.js b/packages/casl-ability/src/ability.js index 9bfee4334..27e5320cd 100755 --- a/packages/casl-ability/src/ability.js +++ b/packages/casl-ability/src/ability.js @@ -108,14 +108,18 @@ export class Ability { return false; } - rulesFor(action, subject, field) { + 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 relevantRules = (specificRules || []).concat(generalRules || []); - return relevantRules.filter(rule => rule.matchesField(subject, field)); + return (specificRules || []).concat(generalRules || []); + } + + rulesFor(action, subject, field) { + return this.possibleRulesFor(action, subject) + .filter(rule => rule.isRelevantFor(subject, field)); } cannot(...args) { diff --git a/packages/casl-ability/src/query.js b/packages/casl-ability/src/extra.js similarity index 52% rename from packages/casl-ability/src/query.js rename to packages/casl-ability/src/extra.js index cc24b9120..973852dfc 100644 --- a/packages/casl-ability/src/query.js +++ b/packages/casl-ability/src/extra.js @@ -24,3 +24,21 @@ export function rulesToQuery(rules, convert) { return rules.length > 0 ? query : null; } + +const getRuleFields = rule => rule.fields; + +export function permittedFieldsOf(ability, action, subject, options = {}) { + const fieldsFrom = options.fieldsFrom || getRuleFields; + const uniqueFields = ability.possibleRulesFor(action, subject).reduce((fields, rule) => { + const names = fieldsFrom(rule); + + if (names && !rule.inverted) { + names.forEach(fields.add, fields); + } + + return fields; + }, new Set()); + + return Array.from(uniqueFields) + .filter(field => ability.can(action, subject, field)); +} diff --git a/packages/casl-ability/src/rule.js b/packages/casl-ability/src/rule.js index 1f70b5639..14c23ebdb 100644 --- a/packages/casl-ability/src/rule.js +++ b/packages/casl-ability/src/rule.js @@ -18,7 +18,7 @@ export class Rule { return !this._matches || typeof object === 'string' || this._matches(object); } - matchesField(object, field) { + isRelevantFor(object, field) { if (!this.fields) { return true; }