Cancancan like authorization plugin for Egg.js
This plugin is our best practice from we developing yuque.com.
$ npm i egg-cancan --save
// {app_root}/config/plugin.js
exports.cancan = {
enable: true,
package: 'egg-cancan',
};
// {app_root}/config/config.default.js
exports.cancan = {
// method name of current logined user instance
contextUserMethod: 'user',
// Enable disable Ability check result cache
cache: false,
// Enable log authorize check result
log: false,
};
You must create app/ability.js
file
The Ability class is where all user permissions are defined. An example class looks like this.
'use strict';
const { BaseAbility } = require('egg-cancan');
class Ability extends BaseAbility {
constructor(ctx, user) {
super(ctx, user)
}
async rules(action, obj, options = {}) {
const { type } = options;
if (type === 'topic') {
if (action === 'update') {
return await this.canUpdateTopic(obj);
}
if (action === 'delete') {
return await this.canDeleteTopic(obj);
}
}
return true;
}
async canUpdateTopic(obj) {
if (topic.user_id === this.user_id) return true;
return false;
}
async canDeleteTopic(obj) {
if (this.user.admin) return true;
return false;
}
}
Action | Alias |
---|---|
read | show, read |
update | edit, update |
create | new, create |
delete | destroy, delete |
Ability support cache Ability check result in ctx, you can enable it by change config/config.default.js
exports.cancan = {
// defalut is disabled
cache: true,
};
When you enable that, you call can
method will hit cache:
ctx.can('read', user);
- check cache in ability._cache
found -> return
not exist ->
execute `rules` to real check
write to _cache
return
Its use action + obj + options
stringify as default cache key:
ability.cacheKey('read', { id: 1 }, { type: 'user' });
=> 'read-{id:1}-{type:"user"}'
You can rewrite it by override the cacheKey
method, for example:
class Ability extends BaseAbility {
cacheKey(action, obj, options) {
return [action, obj.cacheKey, options.type].join(':');
}
}
The ctx.can
method:
can = await ctx.can('create', topic, { type: 'topic' });
can = await ctx.can('read', topic, { type: 'topic' });
can = await ctx.can('update', topic, { type: 'topic' });
can = await ctx.can('delete', topic, { type: 'topic' });
can = await ctx.can('update', user, { type: 'user' });
// For egg-sequelize model instance, not need pass `:type` option
const topic = await ctx.model.Topic.findById(...);
can = await ctx.can('update', topic);
The ctx.authorize
method:
await ctx.authorize('read', topic);
// when permission is ok, not happend
// when no permission, will throw CanCanAccessDenied
If the ctx.authorize
check fails, a CanCanAccessDenied
error will be throw. You can catch this and modify its behavior:
Add new file: app/middleware/handle_authorize.js
module.exports = () => {
return async handleAuthorize(next) {
try {
await next();
} catch (e) {
if (e.name === 'CanCanAccessDenied') {
this.status = 403;
this.body = 'Access Denied';
} else {
throw e;
}
}
}
}
And enable this middleware by modify config/config.default.js
:
exports.middleware = [
...
'handleAuthorize',
...
];
When you wrote app/ability.js
, you may need to write test case.
Create a test file: test/ability.test.js
'use strict';
describe('Ability', () => {
let allow, user, ability, anonymousAbility;
beforeAll(async () => {
user = await create('user');
ability = new app.Ability(ctx, user);
});
describe('Topic', () => {
describe('Anonymous', () => {
it('should work', async () => {
const topic = await create('topic');
allow = await ability.can('create', topic);
assert.equal(true, allow);
allow = await ability.can('read', topic);
assert.equal(true, allow);
allow = await ability.can('update', topic);
assert.equal(false, allow);
allow = await ability.can('destroy', topic);
assert.equal(false, allow);
});
});
describe('Author', () => {
it('should work', async () => {
const topic = await create('topic', { user_id: user.id });
allow = await ability.can('create', topic);
assert.equal(true, allow);
allow = await ability.can('read', topic);
assert.equal(true, allow);
allow = await ability.can('update', topic);
assert.equal(true, allow);
allow = await ability.can('destroy', topic);
assert.equal(true, allow);
});
})
});
});