diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c6c8b36 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.gitignore b/.gitignore index 3c3629e..e6e91f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules +test-sacl.db diff --git a/README.md b/README.md index 766b542..08d2327 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Seriously, why do all these ACL modules have to be so darn complex? I just need The `--save` flag adds `simple-acl` to your `package.json` file. Then: var acl = require('simple-acl'); - + And you're ready to go. --- @@ -35,6 +35,21 @@ Any of the following works: Don't forget to `npm install redis`, too. Since I don't want to force everyone to install `redis` which also includes `hiredis` if they're not gonna use it. +# SQL Database Stores + +Using SQL database is possible via [Bookshelf.js](http://bookshelfjs.org/). +Currently, this package has been tested with MySQL, PostgreSQL, and SQLite. + + npm install bookshelf knex mysql // MySQL + npm install bookshelf knex pg // PostgreSQL + npm install bookshelf knex sqlite3 // SQLite + +Configure your database connections in `knexfile.js`. + +A simple schema is provided in the `migrations` directory. To create the tables: + + npm run migrate:mysql | migrate:sqlite | migrate:pg + --- # API @@ -69,7 +84,7 @@ Yes, `require('acl')` is an `EventEmitter` with two events: acl.on('grant', function(grantee, resource) { }); acl.on('revoke', function(grantee, resource) { }); - + It's pretty basic right now just to allows you to log grants and revokes as they happens. I will add more event-based functionality if there is demand. diff --git a/bookshelf-store.js b/bookshelf-store.js new file mode 100644 index 0000000..71beb8b --- /dev/null +++ b/bookshelf-store.js @@ -0,0 +1,194 @@ +var async = require('async'); +var bookshelf = require('bookshelf'); +var knex = require('knex'); + +var dbName = function(model, field) { + var arr = []; + if (typeof this[model] !== 'undefined') { + arr.push(this[model].prototype.tableName); + } + if (field) { + arr.push(field); + } + return arr.join('.'); +} + +var tableName = function(table, prefix) { + return prefix ? prefix + table : table; +} + +// bookshelf-store.js - bookshelf.js adapter for simple-acl +module.exports = (function() { + + var Store = function(config, prefix) { + var self = this; + var driver = knex(config) + var orm = bookshelf(driver); + var Aro, Aco, Permission; + + this.dbName = dbName.bind(this); + + this.Aro = Aro = orm.Model.extend({ + tableName: tableName('aros', prefix), + hasTimestamps: true, + }); + + this.Permission = Permission = orm.Model.extend({ + tableName: tableName('permissions', prefix), + hasTimestamps: true, + }); + + this.Aco = Aco = orm.Model.extend({ + tableName: tableName('acos', prefix), + hasTimestamps: true, + }); + + this.Aro.getByAlias = function(alias) { + return new Aro({alias: alias}).fetch(); + } + + this.Aco.getByAlias = function(alias) { + return new Aco({alias: alias}).fetch(); + } + + this._getPermission = function(grantee, resource) { + return new Promise(function(resolve, reject) { + Aro.getByAlias(grantee).then(function(aro) { + Aco.getByAlias(resource).then(function(aco) { + resolve({aro: aro, aco: aco}); + }) + }) + }) + } + + this._grant = function(aro, aco, callback) { + Permission.forge({aro_id: aro.id, aco_id: aco.id}).fetch() + .then(function(result) { + if (result) { + return callback ? callback(null, true) : null; + } + this.save().then(function(result) { + return callback ? callback(null, result !== null) : result !== null; + }).catch(function(err) { + return callback ? callback(err) : err; + }); + }).catch(function(err) { + return callback ? callback(err) : err; + }); + } + + }; + + Store.prototype = + { 'createAro': + function(grantee, callback) { + return new this.Aro({alias: grantee}).fetch().then(function(result) { + if (result) { + return callback ? callback(null, result) : result; + } + this.save().then(function(result) { + return callback ? callback(null, result) : result; + }); + }).catch(function(err) { + return callback ? callback(err) : err; + }); + } + + , 'createAco': + function(resource, callback) { + return new this.Aco({alias: resource}).fetch().then(function(result) { + if (result) { + return callback(null, result) + } + this.save().then(function(result) { + return callback(null, result) + }) + }).catch(function(err) { + return callback(err); + }); + } + + , 'grant': + function(grantee, resource, callback) { + var self = this; + this._getPermission(grantee, resource).then(function(result) { + var ctx = { aro: result.aro, aco: result.aco } + async.series({ + createAro: function(next) { + if (ctx.aro) { + return next(); + } + self.createAro(grantee, function(err, aro) { + ctx.aro = aro; + return next(); + }); + }, + + createAco: function(next) { + if (ctx.aco) { + return next(); + } + self.createAco(resource, function(err, aco) { + ctx.aco = aco; + return next(); + }); + }, + + createGrant: function(next) { + self._grant(ctx.aro, ctx.aco, callback); + return next() + }, + }); + }).catch(function(err) { + callback(err); + }) + } + + , 'assert': + function(grantee, resource, callback) { + var self = this; + var promises = []; + var dbName = self.dbName; + + var conditions = {}; + conditions[dbName('Aro', 'alias')] = grantee; + conditions[dbName('Aco', 'alias')] = resource; + + this.Permission.query(function(qb) { + qb + .innerJoin(dbName('Aro'), function() { + this.on(dbName('Permission', 'aro_id'), '=', dbName('Aro', 'id')); + }) + .innerJoin(dbName('Aco'), function() { + this.on(dbName('Permission', 'aco_id'), '=', dbName('Aco', 'id')); + }); + }).where(conditions).fetch().then(function(result) { + return callback(null, result !== null); + }).catch(function(err) { + return callback(err); + }); + + } + + , 'revoke': + function(grantee, resource, callback) { + var self = this; + this._getPermission(grantee, resource).then(function(result) { + if (!result) { + return callback(false); + } + return self.Permission.where({ + aro_id: result.aro.id, + aco_id: result.aco.id, + }).destroy().then(function(result) { + return callback(null, result); + }).catch(function(err) { + return callback(err, result); + }); + }); + } + }; + + return Store; + +})(); diff --git a/index.js b/index.js index aa19c41..300453b 100644 --- a/index.js +++ b/index.js @@ -9,6 +9,12 @@ module.exports = (function() { acl.RedisStore = require('./redis-store'); acl.MockStore = require('./mock-store'); + try { + require.resolve('bookshelf'); + acl.BookshelfStore = require('./bookshelf-store'); + } catch (e) { + } + // function shim var shim = function(obj, action) { var func = obj[action]; diff --git a/knexfile.js b/knexfile.js new file mode 100644 index 0000000..da05c62 --- /dev/null +++ b/knexfile.js @@ -0,0 +1,47 @@ +// Update with your config settings. + +module.exports = { + + dev_mysql: { + client: 'mysql', + connection: { + database: 'test_sacl', + user: 'root', + password: 'password', + timezone: 'UTC' + }, + migrations: { + directory: './migrations', + tableName: 'migrations', + }, + //debug: true, + }, + + dev_sqlite3: { + client: 'sqlite3', + connection: { + filename: './test/test-sacl.db', + }, + useNullAsDefault: true, + migrations: { + directory: './migrations', + tableName: 'migrations', + }, + //debug: true, + }, + + dev_pg: { + client: 'pg', + connection: { + database: 'test_sacl', + user: 'root', + password: 'password', + }, + migrations: { + directory: './migrations', + tableName: 'migrations', + }, + //debug: true, + }, + +}; diff --git a/migrations/201609090919_initial.js b/migrations/201609090919_initial.js new file mode 100644 index 0000000..a7003b5 --- /dev/null +++ b/migrations/201609090919_initial.js @@ -0,0 +1,35 @@ +exports.up = function(knex, Promise) { + return Promise.all([ + + knex.schema.createTable('aros', function(table) { + table.increments('id'); + table.string('alias', 100).notNullable() + .unique('un_aros_alias'); + table.timestamps(); + }), + + knex.schema.createTable('acos', function(table) { + table.increments('id'); + table.string('alias', 100).notNullable() + .unique('un_acos_alias'); + table.timestamps(); + }), + + knex.schema.createTable('permissions', function(table) { + table.increments('id'); + table.integer('aro_id').notNullable(); + table.integer('aco_id').notNullable(); + table.unique(['aro_id', 'aco_id'], 'un_permissions'); + table.timestamps(); + }), + + ]); +}; + +exports.down = function(knex, Promise) { + return Promise.all([ + knex.schema.dropTable('aros'), + knex.schema.dropTable('acos'), + knex.schema.dropTable('permissions'), + ]); +}; diff --git a/package.json b/package.json index affb3d1..e016b99 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,14 @@ "description": "Simple ACL. 'nuff said.", "main": "index.js", "scripts": { + "migrate:mysql": "./node_modules/.bin/knex migrate:latest --env dev_mysql", + "migrate:sqlite": "./node_modules/.bin/knex migrate:latest --env dev_sqlite3", + "migrate:pg": "./node_modules/.bin/knex migrate:latest --env dev_pg", + "rollback:mysql": "./node_modules/.bin/knex migrate:rollback --env dev_mysql", + "rollback:sqlite": "./node_modules/.bin/knex migrate:rollback --env dev_sqlite3", + "rollback:pg": "./node_modules/.bin/knex migrate:rollback --env dev_pg", + "migrate:all": "npm run migrate:mysql; npm run migrate:sqlite; npm run migrate:pg", + "rollback:all": "npm run rollback:mysql; npm run rollback:sqlite; npm run rollback:pg", "test": "mocha --reporter spec" }, "repository": { @@ -13,6 +21,12 @@ "optionalDependencies": { "redis": "~0.8.2" }, + "peerDependencies": { + "bookshelf": "^0.10.*", + "knex": "^0.11.*", + "mysql": "^2.11.*", + "sqlite3": "^3.1.*" + }, "devDependencies": { "mocha": "~1.6.0" }, @@ -20,5 +34,8 @@ "acl" ], "author": "Chakrit Wichian (http://chakrit.net) ", - "license": "BSD" + "license": "BSD", + "dependencies": { + "async": "^2.0.1" + } } diff --git a/test/index.js b/test/index.js index ff1e4c7..a020b6a 100644 --- a/test/index.js +++ b/test/index.js @@ -9,6 +9,12 @@ var stores = 'MemoryStore,RedisStore,MockStore'.split(',') , funcs = 'grant,revoke,assert'.split(','); + try { + require.resolve('bookshelf'); + stores.push('BookshelfStore'); + } catch (e) { + } + // tests describe('acl', function() { before(function() { this.acl = require('..'); }); diff --git a/test/store.js b/test/store.js index 3ed5f83..50445f2 100644 --- a/test/store.js +++ b/test/store.js @@ -3,7 +3,22 @@ (function() { var assert = require('assert') - , acl = require('..'); + , async = require('async') + , acl = require('..') + , knexConfig = require('../knexfile'); + + var loadStore = function(name, module) { + try { + require.resolve(module); + acl[name] = function() { + var config = knexConfig['dev_' + module]; + return new acl.BookshelfStore(config) + }; + stores.push(name); + } catch (e) { + console.log('Not loading ' + name, e.toString()); + } + } // temp string var G = 'grantee', R = 'resource' @@ -11,6 +26,10 @@ var stores = ['MemoryStore', 'RedisStore']; + loadStore('MysqlStore', 'mysql'); + loadStore('SqliteStore', 'sqlite3'); + loadStore('PgStore', 'pg'); + for (var i in stores) (function(storeName) { var Store = acl[storeName]; @@ -68,6 +87,61 @@ }); }); }); + + describe('checking asserts', function() { + before(function(done) { + acl.grant('editor', '/admin/pages', function(err) { + acl.grant('writer', '/admin/pages', function(err) { + acl.grant('editor', '/admin/pages/approve', done); + }); + }); + }); + after(function(done) { + acl.revoke('editor', '/admin/pages', function(err) { + acl.revoke('writer', '/admin/pages', function(err) { + acl.revoke('editor', '/admin/pages/approve', done); + }); + }); + }); + + it('editor can access /admin/pages', function(done) { + acl.assert('editor', '/admin/pages', function(e, success) { + done(e, assert(success === true)); + }); + }); + + it('editor can access /admin/pages/approve', function(done) { + acl.assert('editor', '/admin/pages/approve', function(e, success) { + done(e, assert(success === true)); + }); + }); + + it('writer can access /admin/pages', function(done) { + acl.assert('writer', '/admin/pages', function(e, success) { + done(e, assert(success === true)); + }); + }); + + it('writer cannot access /admin/pages/approve', function(done) { + acl.assert('writer', '/admin/pages/approve', function(e, success) { + done(e, assert(success === false)); + }); + }); + + it(G + ' cannot access /admin/pages', function(done) { + acl.assert(G, '/admin/pages', function(e, success) { + done(e, assert(success === false)); + }); + }); + + it(G + ' cannot access /admin/pages/approve', function(done) { + acl.assert(G, '/admin/pages/approve', function(e, success) { + done(e, assert(success === false)); + }); + }); + + }); + }); })(stores[i]);