Permissions / ACL

- Created Role model
- Created Permission model
- Linked Users->Roles with a belongsToMany relationship
- Linked Permissions to Users and Roles with a belongsToMany relationship
- Created permissions helper with functions for initializing and
  checking permissions (canThis)
- Unit tests for lots of things
This commit is contained in:
Jacob Gable 2013-06-04 22:47:11 -05:00 committed by ErisDS
parent d047692c73
commit e6f7c706cb
14 changed files with 793 additions and 7 deletions

View File

@ -63,5 +63,71 @@ module.exports = {
"created_by": 1,
"updated_by": 1
}
],
roles: [
{
"id": 1,
"name": "Administrator",
"description": "Administrators"
},
{
"id": 2,
"name": "Editor",
"description": "Editors"
},
{
"id": 3,
"name": "Author",
"description": "Authors"
}
],
roles_users: [
{
"id": 1,
"role_id": 1,
"user_id": 1
}
],
permissions: [
{
"id": 1,
"name": "Edit posts",
"action_type": "edit",
"object_type": "post"
},
{
"id": 2,
"name": "Remove posts",
"action_type": "remove",
"object_type": "post"
},
{
"id": 3,
"name": "Create posts",
"action_type": "create",
"object_type": "post"
}
],
permissions_roles: [
{
"id": 1,
"permission_id": 1,
"role_id": 1
},
{
"id": 2,
"permission_id": 2,
"role_id": 1
},
{
"id": 3,
"permission_id": 3,
"role_id": 1
}
]
};

View File

@ -47,6 +47,38 @@
t.integer('updated_by');
}),
knex.Schema.createTable('roles', function (t) {
t.increments().primary();
t.string('name');
t.string('description');
}),
knex.Schema.createTable('roles_users', function (t) {
t.increments().primary();
t.integer('role_id');
t.integer('user_id');
}),
knex.Schema.createTable('permissions', function (t) {
t.increments().primary();
t.string('name');
t.string('object_type');
t.string('action_type');
t.integer('object_id');
}),
knex.Schema.createTable('permissions_users', function (t) {
t.increments().primary();
t.integer('user_id');
t.integer('permission_id');
}),
knex.Schema.createTable('permissions_roles', function(t) {
t.increments().primary();
t.integer('role_id');
t.integer('permission_id');
}),
knex.Schema.createTable('settings', function (t) {
t.increments().primary();
t.string('key');
@ -63,6 +95,10 @@
return when.all([
knex('posts').insert(fixtures.posts),
knex('users').insert(fixtures.users),
knex('roles').insert(fixtures.roles),
knex('roles_users').insert(fixtures.roles_users),
knex('permissions').insert(fixtures.permissions),
knex('permissions_roles').insert(fixtures.permissions_roles),
knex('settings').insert(fixtures.settings)
]);
@ -74,8 +110,17 @@
return when.all([
knex.Schema.dropTableIfExists("posts"),
knex.Schema.dropTableIfExists("users"),
knex.Schema.dropTableIfExists("settings")
]);
knex.Schema.dropTableIfExists("roles"),
knex.Schema.dropTableIfExists("settings"),
knex.Schema.dropTableIfExists("permissions")
]).then(function() {
// Drop the relation tables after the model tables?
return when.all([
knex.Schema.dropTableIfExists("roles_users"),
knex.Schema.dropTableIfExists("permissions_users"),
knex.Schema.dropTableIfExists("permissions_roles")
]);
});
};
exports.up = up;

View File

@ -21,6 +21,7 @@
},
logError: function (err) {
err = err || "Unknown";
// TODO: Logging framework hookup
console.log("Error occurred: ", err.message || err);
},

View File

@ -9,6 +9,8 @@
module.exports = {
Post: require('./post').Post,
User: require('./user').User,
Role: require('./role').Role,
Permission: require('./permission').Permission,
Setting: require('./setting').Setting,
init: function () {
return knex.Schema.hasTable('posts').then(null, function () {

View File

@ -0,0 +1,31 @@
(function () {
"use strict";
var GhostBookshelf = require('./base'),
User = require('./user').User,
Role = require('./role').Role,
Permission,
Permissions;
Permission = GhostBookshelf.Model.extend({
tableName: 'permissions',
roles: function () {
return this.belongsToMany(Role);
},
users: function () {
return this.belongsToMany(User);
}
});
Permissions = GhostBookshelf.Collection.extend({
model: Permission
});
module.exports = {
Permission: Permission,
Permissions: Permissions
};
}());

View File

@ -0,0 +1,32 @@
(function () {
"use strict";
var User = require('./user').User,
Permission = require('./permission').Permission,
GhostBookshelf = require('./base'),
Role,
Roles;
Role = GhostBookshelf.Model.extend({
tableName: 'roles',
users: function () {
return this.belongsToMany(User);
},
permissions: function () {
return this.belongsToMany(Permission);
}
});
Roles = GhostBookshelf.Collection.extend({
model: Role
});
module.exports = {
Role: Role,
Roles: Roles
};
}());

View File

@ -8,7 +8,9 @@
nodefn = require('when/node/function'),
bcrypt = require('bcrypt-nodejs'),
Posts = require('./post').Posts,
GhostBookshelf = require('./base');
GhostBookshelf = require('./base'),
Role = require('./role').Role,
Permission = require('./permission').Permission;
User = GhostBookshelf.Model.extend({
@ -18,6 +20,14 @@
posts: function () {
return this.hasMany(Posts, 'created_by');
},
roles: function () {
return this.belongsToMany(Role);
},
permissions: function () {
return this.belongsToMany(Permission);
}
}, {
@ -62,6 +72,35 @@
return user;
});
});
},
effectivePermissions: function (id) {
return this.read({id: id}, { withRelated: ['permissions', 'roles.permissions'] })
.then(function (foundUser) {
var seenPerms = {},
rolePerms = _.map(foundUser.related('roles').models, function (role) {
return role.related('permissions').models;
}),
allPerms = [];
rolePerms.push(foundUser.related('permissions').models);
_.each(rolePerms, function (rolePermGroup) {
_.each(rolePermGroup, function (perm) {
var key = perm.get('action_type') + '-' + perm.get('object_type') + '-' + perm.get('object_id');
// Only add perms once
if (seenPerms[key]) {
return;
}
allPerms.push(perm);
seenPerms[key] = true;
});
});
return when.resolve(allPerms);
});
}
});

View File

@ -0,0 +1,160 @@
(function () {
"use strict";
// canThis(someUser).edit.posts([id]|[[ids]])
// canThis(someUser).edit.post(somePost|somePostId)
var _ = require('underscore'),
when = require('when'),
Models = require('../models'),
UserProvider = Models.User,
PermissionsProvider = Models.Permission,
init,
refresh,
canThis,
CanThisResult,
exported;
// Base class for canThis call results
CanThisResult = function () {
this.userPermissionLoad = false;
};
CanThisResult.prototype.buildObjectTypeHandlers = function (obj_types, act_type) {
var self = this,
obj_type_handlers = {};
// Iterate through the object types, i.e. ['post', 'tag', 'user']
_.each(obj_types, function (obj_type) {
// Create the 'handler' for the object type;
// the '.post()' in canThis(user).edit.post()
obj_type_handlers[obj_type] = function (modelOrId) {
var modelId;
if (_.isNumber(modelOrId) || _.isString(modelOrId)) {
// It's an id already, do nothing
modelId = modelOrId;
} else if (modelOrId) {
// It's a model, get the id
modelId = modelOrId.id;
}
// Wait for the user loading to finish
return self.userPermissionLoad.then(function (userPermissions) {
// Iterate through the user permissions looking for an affirmation
var hasPermission = _.any(userPermissions, function (userPermission) {
var permObjId;
// Look for a matching action type and object type first
if (userPermission.get('action_type') !== act_type || userPermission.get('object_type') !== obj_type) {
return false;
}
// Grab the object id (if specified, could be null)
permObjId = userPermission.get('object_id');
// If we didn't specify a model (any thing)
// or the permission didn't have an id scope set
// then the user has permission
if (!modelId || !permObjId) {
return true;
}
// Otherwise, check if the id's match
// TODO: String vs Int comparison possibility here?
return modelId === permObjId;
});
if (hasPermission) {
return when.resolve();
}
return when.reject();
});
};
});
return obj_type_handlers;
};
CanThisResult.prototype.beginCheck = function (user) {
var self = this;
// TODO: Switch logic based on object type; user, role, post.
// Kick off the fetching of the user data
this.userPermissionLoad = UserProvider.effectivePermissions(user.id || user);
// Iterate through the actions and their related object types
// We should have loaded these through a permissions.init() call previously
// TODO: Throw error if not init() yet?
_.each(exported.actionsMap, function (obj_types, act_type) {
// Build up the object type handlers;
// the '.post()' parts in canThis(user).edit.post()
var obj_type_handlers = self.buildObjectTypeHandlers(obj_types, act_type);
// Define a property for the action on the result;
// the '.edit' in canThis(user).edit.post()
Object.defineProperty(self, act_type, {
writable: false,
enumerable: false,
configurable: false,
value: obj_type_handlers
});
});
// Return this for chaining
return this;
};
canThis = function (user) {
var result = new CanThisResult();
return result.beginCheck(user);
};
init = refresh = function () {
// Load all the permissions
return PermissionsProvider.browse().then(function (perms) {
var seenActions = {};
exported.actionsMap = {};
// Build a hash map of the actions on objects, i.e
/*
{
'edit': ['post', 'tag', 'user', 'page'],
'delete': ['post', 'user'],
'create': ['post', 'user', 'page']
}
*/
_.each(perms.models, function (perm) {
var action_type = perm.get('action_type'),
object_type = perm.get('object_type');
exported.actionsMap[action_type] = exported.actionsMap[action_type] || [];
seenActions[action_type] = seenActions[action_type] || {};
// Check if we've already seen this action -> object combo
if (seenActions[action_type][object_type]) {
return;
}
exported.actionsMap[action_type].push(object_type);
seenActions[action_type][object_type] = true;
});
return when(exported.actionsMap);
});
};
module.exports = exported = {
init: init,
refresh: refresh,
canThis: canThis,
actionsMap: {}
};
}());

View File

@ -0,0 +1,171 @@
/*globals describe, beforeEach, it*/
(function () {
"use strict";
var should = require('should'),
helpers = require('./helpers'),
errors = require('../../shared/errorHandling'),
Models = require('../../shared/models');
describe("Role Model", function () {
var RoleModel = Models.Role;
should.exist(RoleModel);
beforeEach(function(done) {
helpers.resetData().then(function() {
done();
}, done);
});
it("can browse roles", function (done) {
RoleModel.browse().then(function (foundRoles) {
should.exist(foundRoles);
foundRoles.models.length.should.be.above(0);
done();
}, errors.logError);
});
it("can read roles", function (done) {
RoleModel.read({id: 1}).then(function (foundRole) {
should.exist(foundRole);
done();
}, errors.logError);
});
it("can edit roles", function (done) {
RoleModel.read({id: 1}).then(function (foundRole) {
should.exist(foundRole);
return foundRole.set({name: "updated"}).save();
}).then(function () {
return RoleModel.read({id: 1});
}).then(function (updatedRole) {
should.exist(updatedRole);
updatedRole.get("name").should.equal("updated");
done();
}, errors.logError);
});
it("can add roles", function (done) {
var newRole = {
name: "test1",
description: "test1 description"
};
RoleModel.add(newRole).then(function (createdRole) {
should.exist(createdRole);
createdRole.attributes.name.should.equal(newRole.name);
createdRole.attributes.description.should.equal(newRole.description);
done();
}, done);
});
it("can delete roles", function (done) {
RoleModel.read({id: 1}).then(function (foundRole) {
should.exist(foundRole);
return RoleModel['delete'](1);
}).then(function () {
return RoleModel.browse();
}).then(function (foundRoles) {
var hasRemovedId = foundRoles.any(function(role) {
return role.id === 1;
});
hasRemovedId.should.equal(false);
done();
}, errors.logError);
});
});
describe("Permission Model", function () {
var PermissionModel = Models.Permission;
should.exist(PermissionModel);
beforeEach(function(done) {
helpers.resetData().then(function() {
done();
}, done);
});
it("can browse permissions", function (done) {
PermissionModel.browse().then(function (foundPermissions) {
should.exist(foundPermissions);
foundPermissions.models.length.should.be.above(0);
done();
}, errors.logError);
});
it("can read permissions", function (done) {
PermissionModel.read({id: 1}).then(function (foundPermission) {
should.exist(foundPermission);
done();
}, errors.logError);
});
it("can edit permissions", function (done) {
PermissionModel.read({id: 1}).then(function (foundPermission) {
should.exist(foundPermission);
return foundPermission.set({name: "updated"}).save();
}).then(function () {
return PermissionModel.read({id: 1});
}).then(function (updatedPermission) {
should.exist(updatedPermission);
updatedPermission.get("name").should.equal("updated");
done();
}, errors.logError);
});
it("can add permissions", function (done) {
var newPerm = {
name: "testperm1"
};
PermissionModel.add(newPerm).then(function (createdPerm) {
should.exist(createdPerm);
createdPerm.attributes.name.should.equal(newPerm.name);
done();
}, done);
});
it("can delete permissions", function (done) {
PermissionModel.read({id: 1}).then(function (foundPermission) {
should.exist(foundPermission);
return PermissionModel['delete'](1);
}).then(function () {
return PermissionModel.browse();
}).then(function (foundPermissions) {
var hasRemovedId = foundPermissions.any(function(permission) {
return permission.id === 1;
});
hasRemovedId.should.equal(false);
done();
}, errors.logError);
});
});
}());

View File

@ -8,7 +8,7 @@
helpers = require('./helpers'),
Models = require('../../shared/models');
describe('Bookshelf Post Model', function () {
describe('Post Model', function () {
var PostModel = Models.Post;

View File

@ -8,7 +8,7 @@
helpers = require('./helpers'),
Models = require('../../shared/models');
describe('Bookshelf Setting Model', function () {
describe('Setting Model', function () {
var SettingModel = Models.Setting;

View File

@ -6,9 +6,10 @@
var _ = require('underscore'),
should = require('should'),
helpers = require('./helpers'),
errors = require('../../shared/errorHandling'),
Models = require('../../shared/models');
describe('Bookshelf User Model', function () {
describe('User Model', function () {
var UserModel = Models.User;
@ -134,6 +135,16 @@
}).then(null, done);
});
it("can get effective permissions", function (done) {
UserModel.effectivePermissions(1).then(function (effectivePermissions) {
should.exist(effectivePermissions);
effectivePermissions.length.should.be.above(0);
done();
}, errors.logError);
});
});
}());

View File

@ -11,10 +11,12 @@
describe("Ghost API", function () {
it("is a singleton", function () {
var ghost1 = new Ghost(),
var logStub = sinon.stub(console, "log"),
ghost1 = new Ghost(),
ghost2 = new Ghost();
should.strictEqual(ghost1, ghost2);
logStub.restore();
});
it("uses init() to initialize", function (done) {

View File

@ -0,0 +1,226 @@
/*globals describe, beforeEach, it*/
(function () {
"use strict";
var _ = require("underscore"),
when = require('when'),
should = require('should'),
errors = require('../../shared/errorHandling'),
helpers = require('./helpers'),
permissions = require('../../shared/permissions'),
Models = require('../../shared/models'),
UserProvider = Models.User,
PermissionsProvider = Models.Permission;
describe('permissions', function () {
should.exist(permissions);
beforeEach(function (done) {
helpers.resetData().then(function () { done(); }, errors.throwError);
});
var testPerms = [
{ act: "edit", obj: "post" },
{ act: "edit", obj: "tag" },
{ act: "edit", obj: "user" },
{ act: "edit", obj: "page" },
{ act: "add", obj: "post" },
{ act: "add", obj: "user" },
{ act: "add", obj: "page" },
{ act: "remove", obj: "post" },
{ act: "remove", obj: "user" }
],
currTestPermId = 1,
createPermission = function (name, act, obj) {
if (!name) {
currTestPermId += 1;
name = "test" + currTestPermId;
}
var newPerm = {
name: name,
action_type: act,
object_type: obj
};
return PermissionsProvider.add(newPerm);
},
createTestPermissions = function() {
var createActions = _.map(testPerms, function (testPerm) {
return createPermission(null, testPerm.act, testPerm.obj);
});
return when.all(createActions);
};
it('can load an actions map from existing permissions', function (done) {
createTestPermissions()
.then(permissions.init)
.then(function (actionsMap) {
should.exist(actionsMap);
actionsMap.edit.should.eql(['post', 'tag', 'user', 'page']);
actionsMap.should.equal(permissions.actionsMap);
done();
}, errors.throwError);
});
it('can add user to role', function (done) {
var existingUserRoles;
UserProvider.read({id: 1}, { withRelated: ['roles'] }).then(function (foundUser) {
var testRole = new Models.Role({
name: 'testrole1',
description: 'testrole1 description'
});
should.exist(foundUser);
should.exist(foundUser.roles());
existingUserRoles = foundUser.related('roles').length;
return testRole.save().then(function () {
return foundUser.roles().attach(testRole);
});
}).then(function () {
return UserProvider.read({id: 1}, { withRelated: ['roles'] });
}).then(function (updatedUser) {
should.exist(updatedUser);
updatedUser.related('roles').length.should.equal(existingUserRoles + 1);
done();
});
});
it('can add user permissions', function (done) {
Models.User.read({id: 1}, { withRelated: ['permissions']}).then(function (testUser) {
var testPermission = new Models.Permission({
name: "test edit posts",
action_type: 'edit',
object_type: 'post'
});
testUser.related('permissions').length.should.equal(0);
return testPermission.save().then(function () {
return testUser.permissions().attach(testPermission);
});
}).then(function () {
return Models.User.read({id: 1}, { withRelated: ['permissions']});
}).then(function (updatedUser) {
should.exist(updatedUser);
updatedUser.related('permissions').length.should.equal(1);
done();
});
});
it('can add role permissions', function (done) {
var testRole = new Models.Role({
name: "test2",
description: "test2 description"
});
testRole.save().then(function () {
return testRole.load('permissions');
}).then(function () {
var rolePermission = new Models.Permission({
name: "test edit posts",
action_type: 'edit',
object_type: 'post'
});
testRole.related('permissions').length.should.equal(0);
return rolePermission.save().then(function () {
return testRole.permissions().attach(rolePermission);
});
}).then(function () {
return Models.Role.read({id: testRole.id}, { withRelated: ['permissions']});
}).then(function (updatedRole) {
should.exist(updatedRole);
updatedRole.related('permissions').length.should.equal(1);
done();
});
});
it('does not allow edit post without permission', function (done) {
var fakePage = {
id: 1
};
createTestPermissions()
.then(permissions.init)
.then(function () {
return Models.User.read({id: 1});
})
.then(function (foundUser) {
var canThisResult = permissions.canThis(foundUser);
should.exist(canThisResult.edit);
should.exist(canThisResult.edit.post);
return canThisResult.edit.page(fakePage);
})
.then(function () {
errors.logError(new Error("Allowed edit post without permission"));
}, function () {
done();
});
});
it('allows edit post with permission', function (done) {
var fakePost = {
id: "1"
};
createTestPermissions()
.then(permissions.init)
.then(function () {
return Models.User.read({id: 1});
})
.then(function (foundUser) {
var newPerm = new Models.Permission({
name: "test3 edit post",
action_type: "edit",
object_type: "post"
});
return newPerm.save().then(function () {
return foundUser.permissions().attach(newPerm);
});
})
.then(function () {
return Models.User.read({id: 1}, { withRelated: ['permissions']});
})
.then(function (updatedUser) {
// TODO: Verify updatedUser.related('permissions') has the permission?
var canThisResult = permissions.canThis(updatedUser);
should.exist(canThisResult.edit);
should.exist(canThisResult.edit.post);
return canThisResult.edit.post(fakePost);
})
.then(function () {
done();
}, function () {
errors.logError(new Error("Did not allow edit post with permission"));
});
});
});
}());