├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── README.md ├── TODO.md ├── circle.yml ├── lib ├── index.js ├── middleware │ ├── access-logger.js │ └── user-context.js ├── mixins │ └── get-current-user.js └── utils.js ├── package.json └── test ├── .eslintrc ├── fixtures └── simple-app │ ├── common │ └── models │ │ ├── access-token.json │ │ ├── invoice.json │ │ ├── store.json │ │ ├── team.json │ │ ├── transaction.json │ │ ├── user.js │ │ └── user.json │ ├── fixtures │ ├── AccessToken.json │ ├── Invoice.json │ ├── Store.json │ ├── Team.json │ ├── Transaction.json │ └── user.json │ └── server │ ├── boot │ ├── authentication.js │ └── root.js │ ├── component-config.json │ ├── config.json │ ├── datasources.json │ ├── middleware.development.json │ ├── middleware.json │ ├── model-config.json │ └── server.js ├── middleware-test.js ├── mixin-test.js ├── rest-test.js └── utils-test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # http://editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | indent_style = space 9 | indent_size = 2 10 | end_of_line = lf 11 | charset = utf-8 12 | trim_trailing_whitespace = true 13 | insert_final_newline = true 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | extends: fullcube, 3 | root: true, 4 | rules: { 5 | no-console: 0, 6 | class-methods-use-this: 0 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | .nyc_output 4 | npm-debug.log 5 | node_modules 6 | coverage 7 | db.json 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Loopback Component Group Access 2 | 3 | [![Greenkeeper badge](https://badges.greenkeeper.io/fullcube/loopback-component-access-groups.svg)](https://greenkeeper.io/) 4 | 5 | [![Circle CI](https://circleci.com/gh/fullcube/loopback-component-access-groups.svg?style=svg)](https://circleci.com/gh/fullcube/loopback-component-access-groups) [![Dependencies](http://img.shields.io/david/fullcube/loopback-component-access-groups.svg?style=flat)](https://david-dm.org/fullcube/loopback-component-access-groups) [![Coverage Status](https://coveralls.io/repos/github/fullcube/loopback-component-access-groups/badge.svg?branch=master)](https://coveralls.io/github/fullcube/loopback-component-access-groups?branch=master) 6 | 7 | This loopback component enables you to add multi-tenant style access controls to a loopback application. It enables you to restrict access to model data based on a user's roles within a specific context. 8 | 9 | There are two types of access restrictions implemented in this component: 10 | 11 | **1) Role Resolvers** 12 | 13 | For each *Group Role* that you define, a dynamic [Role Resolver](https://docs.strongloop.com/display/public/LB/Defining+and+using+roles#Definingandusingroles-Dynamicroles) is attached to the application. These Role Resolvers are responsible for determining wether or not a user has the relevant roles required to access data that belongs to a group context. 14 | 15 | 16 | **2) Query Filters** 17 | 18 | An 'access' [Operation Hook](https://docs.strongloop.com/display/public/LB/Operation+hooks) is injected into each Group Content model. This is used to filter search results to ensure that only items that a user has access to (based on their Group Roles) are returned. 19 | 20 | ### Installation 21 | 22 | 1. Install in you loopback project: 23 | 24 | `npm install --save loopback-component-access-groups` 25 | 26 | 2. Create a component-config.json file in your server folder (if you don't already have one) 27 | 28 | 3. Configure options inside `component-config.json`. *(see configuration section)* 29 | 30 | ```json 31 | { 32 | "loopback-component-access-groups": { 33 | "{option}": "{value}" 34 | } 35 | } 36 | ``` 37 | 38 | 4. Create a middleware.json file in your server folder (if you don't already have one). 39 | 40 | 5. Ensure that you enable the `loopback#context`, `loopback#token` middleware early in your middleware chain. 41 | 42 | ```json 43 | { 44 | "initial:before": { 45 | "loopback#context": {}, 46 | "loopback#token": {} 47 | }, 48 | } 49 | ``` 50 | 51 | ### Usage 52 | 53 | **Group Model** 54 | 55 | You will need to designate one of your models as the *Group Model*. This model will act as parent or container for related group specific data. 56 | 57 | All models that have a belongsTo relationship to your *Group Model* will be considered as Group Content. Access grants for Group Content are determined by a user's roles within the context of its group as defined in the *Group Access Model*. 58 | 59 | **Group Roles** 60 | 61 | *Group Roles* can be used in ACL definitions to grant or restrict access to api endpoints to specific group roles. 62 | 63 | ``` 64 | { 65 | "accessType": "READ", 66 | "principalType": "ROLE", 67 | "principalId": "$group:member", 68 | "permission": "ALLOW" 69 | } 70 | ``` 71 | 72 | The above configuration would grant READ access to all users that have the 'member' role within the context of the group that a model instance belongs to. 73 | 74 | *Group Roles* can be defined in the component configuration using the `groupRoles` key. *Group Role* names must be prefixed with `$group:` (eg `$group:admin`). 75 | 76 | **Group Access Model** 77 | 78 | In order to use this component you will need to create *Group Access Model* that can be used to assign roles to users of a Group. A user can have have multiple roles within the context of a group and each role can be associated with different access grants to REST resources. The default schema for the *Group Access Model* is as follows, although this can be overridden through the component configuration options. 79 | 80 | - User -> hasMany -> Groups (through GroupAccess) 81 | - Group -> hasMany -> Users (through GroupAccess) 82 | - GroupAccess 83 | - userId -> belongsTo -> User 84 | - groupId -> belongsTo -> Group 85 | - role 86 | 87 | ### Example 88 | 89 | - **Group Model:** Store (id, name, description) 90 | - **Group Access Model:** StoreUsers (userid, storeId, role) 91 | - **Group Content Models:** Product, Invoice, Transaction, etc. 92 | - **Group Roles:** Store Manager, Store Administrator 93 | 94 | - You have multiple stores. 95 | - Each store can have multiple Store Users. 96 | - Each Store User can have one or more Store Roles (eg, Store Manager, Store Administrator). 97 | - Only Store Managers of Store A can create and edit products for Store A. 98 | - Only Store Managers of Store B can create and edit products for Store B. 99 | - Only Store Administrators of Store A can download transaction details for Store A. 100 | - Only Store Administrators of Store B can download transaction details for Store B. 101 | 102 | ### Configuration 103 | 104 | Options: 105 | 106 | - `userModel` 107 | 108 | [String] : The name of the user model. *(default: 'User')* 109 | 110 | - `roleModel` 111 | 112 | [String] : The name of the model that should be used to register group access role resolvers. *(default: 'Role')* 113 | 114 | - `groupModel` 115 | 116 | [String] : The model that is considered as a group. *(default: 'Group')* 117 | 118 | - `groupAccessModel` 119 | 120 | [String] : The name of the model that should be used to store and check group access roles. *(default: 'GroupAccess')* 121 | 122 | - `foreignKey` 123 | 124 | [String] : The foreign key that should be used to determine which access group a model belongs to. *(default: 'groupId')* 125 | 126 | - `groupRoles` 127 | 128 | [Array] : A list of group names. *(default: [ '$group:admin', '$group:member' ])* 129 | 130 | - `applyToStatic` 131 | 132 | [Boolean] : Set to *true* to apply ACLs to static methods (by means of query filtering). *(default: false)* 133 | 134 | 135 | ## Tests 136 | 137 | A sample application is provided in the test directory. This demonstrates how you can integrate the component with a loopback application. 138 | 139 | The following group roles roles are configured in the test data. 140 | 141 | - **$group:member** 142 | read 143 | 144 | - **$group:manager** 145 | create, read, update 146 | 147 | - **$group:admin** 148 | create, read, update, delete 149 | 150 | There are a number of test user accounts in the sample application. 151 | 152 | - generalUser 153 | - (no group roles) 154 | - storeAdminA 155 | - ($group:admin of Store A) 156 | - storeManagerA 157 | - ($group:manager of Store A) 158 | - storeMemberA 159 | - ($group:member of Store A) 160 | - storeAdminB 161 | - ($group:admin of Store B) 162 | - storeManagerB 163 | - ($group:manager of Store B) 164 | - storeMemberB 165 | - ($group:member of Store B) 166 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | ### TODOs 2 | | Filename | line # | TODO 3 | |:------|:------:|:------ 4 | | lib/index.js | 25 | Create Group Access model automatically if one hasn't been specified 5 | | lib/utils.js | 139 | Should we allow the access group model to be treated as a group content model too? 6 | | lib/utils.js | 281 | Use promise cancellation to abort the chain early. 7 | | lib/utils.js | 339 | Cache this result so that it can be reused across each ACL lookup attempt. 8 | | lib/utils.js | 346 | Attempt to follow relationships in addition to the foreign key. 9 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 6.1.0 4 | test: 5 | post: 6 | - npm run coverage 7 | deployment: 8 | master: 9 | branch: master 10 | commands: 11 | - npm run semantic-release 12 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const debug = require('debug')('loopback:component:access') 4 | const AccessUtils = require('./utils') 5 | const accessLogger = require('./middleware/access-logger') 6 | const userContext = require('./middleware/user-context') 7 | 8 | module.exports = function loopbackComponentAccess(app, options) { 9 | debug('initializing component') 10 | const { loopback } = app 11 | const loopbackMajor = (loopback && loopback.version && loopback.version.split('.')[0]) || 1 12 | 13 | if (loopbackMajor < 2) { 14 | throw new Error('loopback-component-access-groups requires loopback 2.0 or newer') 15 | } 16 | 17 | // Initialize middleware. 18 | app.middleware('auth:after', userContext()) 19 | app.middleware('routes:before', accessLogger()) 20 | 21 | // Initialise helper class. 22 | app.accessUtils = new AccessUtils(app, options) 23 | 24 | // Initialize remoting phase. 25 | app.accessUtils.setupRemotingPhase() 26 | 27 | // Set up role resolvers. 28 | app.accessUtils.setupRoleResolvers() 29 | 30 | // Set up model opertion hooks. 31 | if (options.applyToStatic) { 32 | app.accessUtils.setupFilters() 33 | } 34 | 35 | // TODO: Create Group Access model automatically if one hasn't been specified 36 | } 37 | -------------------------------------------------------------------------------- /lib/middleware/access-logger.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const LoopBackContext = require('loopback-context') 4 | const debug = require('debug')('loopback:component:access:logger') 5 | 6 | module.exports = function accessLoggerMiddleware() { 7 | debug('initializing access logger middleware') 8 | return function accessLogger(req, res, next) { 9 | const loopbackContext = LoopBackContext.getCurrentContext({ bind: true }) 10 | 11 | next = loopbackContext.bind(next) 12 | 13 | if (req.accessToken) { 14 | debug('req: %s %s, token: %o', req.method, req.originalUrl, req.accessToken) 15 | } 16 | else { 17 | debug('req', req.method, req.originalUrl) 18 | } 19 | 20 | const start = new Date() 21 | 22 | if (res._responseTime) { 23 | return next() 24 | } 25 | res._responseTime = true 26 | 27 | // install a listener for when the response is finished 28 | res.on('finish', () => { 29 | // the request was handled, print the log entry 30 | const duration = new Date() - start 31 | 32 | debug('res %s %s: %o', req.method, req.originalUrl, { 33 | lbHttpMethod: req.method, 34 | lbUrl: req.originalUrl, 35 | lbStatusCode: res.statusCode, 36 | lbResponseTime: duration, 37 | lbResponseTimeUnit: 'ms', 38 | } 39 | ) 40 | }) 41 | 42 | return next() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/middleware/user-context.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const debug = require('debug')('loopback:component:access:context') 4 | const Promise = require('bluebird') 5 | const LoopBackContext = require('loopback-context') 6 | 7 | module.exports = function userContextMiddleware() { 8 | debug('initializing user context middleware') 9 | // set current user to enable user access for remote methods 10 | return function userContext(req, res, next) { 11 | const loopbackContext = LoopBackContext.getCurrentContext({ bind: true }) 12 | 13 | next = loopbackContext.bind(next) 14 | 15 | if (!loopbackContext) { 16 | debug('No user context (loopback current context not found)') 17 | return next() 18 | } 19 | 20 | if (!req.accessToken) { 21 | debug('No user context (access token not found)') 22 | return next() 23 | } 24 | 25 | const { app } = req 26 | const UserModel = app.accessUtils.options.userModel || 'User' 27 | 28 | return Promise.join( 29 | app.models[UserModel].findById(req.accessToken.userId), 30 | app.accessUtils.getUserGroups(req.accessToken.userId), 31 | (user, groups) => { 32 | if (!user) { 33 | return next(new Error('No user with this access token was found.')) 34 | } 35 | loopbackContext.set('currentUser', user) 36 | loopbackContext.set('currentUserGroups', groups) 37 | debug('currentUser', user) 38 | debug('currentUserGroups', groups) 39 | return next() 40 | }) 41 | .catch(next) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/mixins/get-current-user.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const debug = require('debug')('loopback:component:access:utils') 4 | const LoopBackContext = require('loopback-context') 5 | 6 | module.exports = function getCurrentUserMixin(Model) { 7 | debug('initializing GetCurrentUser Mixin for model %s', Model.modelName) 8 | 9 | Model.getCurrentUser = function getCurrentUser() { 10 | const ctx = LoopBackContext.getCurrentContext({ bind: true }) 11 | const currentUser = (ctx && ctx.get('currentUser')) || null 12 | 13 | if (ctx) { 14 | debug(`${Model.definition.name}.getCurrentUser() - currentUser: %o`, currentUser) 15 | } 16 | else { 17 | // this means its a server-side logic call w/o any HTTP req/resp aspect to it. 18 | debug(`${Model.definition.name}.getCurrentUser() - no loopback context`) 19 | } 20 | 21 | return currentUser 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const debug = require('debug')('loopback:component:access') 4 | const { createPromiseCallback } = require('loopback-datasource-juggler/lib/utils') 5 | const _defaults = require('lodash').defaults 6 | const _get = require('lodash').get 7 | const _set = require('lodash').set 8 | const Promise = require('bluebird') 9 | const LoopBackContext = require('loopback-context') 10 | 11 | module.exports = class AccessUtils { 12 | constructor(app, options) { 13 | this.app = app 14 | 15 | this.options = _defaults({ }, options, { 16 | userModel: 'User', 17 | roleModel: 'Role', 18 | groupAccessModel: 'GroupAccess', 19 | groupModel: 'Group', 20 | foreignKey: 'groupId', 21 | groupRoles: [ 22 | '$group:admin', 23 | '$group:member', 24 | ], 25 | applyToStatic: false, 26 | }) 27 | // Default the foreignKey to the group model name + Id. 28 | this.options.foreignKey = this.options.foreignKey || `${this.options.groupModel.toLowerCase()}Id` 29 | 30 | // Validate the format of options.groupRoles ($group:[role]). 31 | this.options.groupRoles.forEach(name => { 32 | if (!this.isValidPrincipalId(name)) { 33 | throw new Error('$name is an invalid access group name.') 34 | } 35 | }) 36 | 37 | // Save the component config for easy reference. 38 | app.set('loopback-component-access-groups', options) 39 | debug('options: %o', options) 40 | } 41 | 42 | /** 43 | * Register a custom remoting phase to make the current user details available from remoting contexts. 44 | */ 45 | setupRemotingPhase() { 46 | this.app.remotes().phases 47 | .addBefore('invoke', 'options-from-request') 48 | .use((ctx, next) => { 49 | if (!_get(ctx, 'args.options.accessToken')) { 50 | return next() 51 | } 52 | _set(ctx, 'args.options.currentUser', this.getCurrentUser()) 53 | _set(ctx, 'args.options.currentUserGroups', this.getCurrentUserGroups()) 54 | return next() 55 | }) 56 | } 57 | 58 | /** 59 | * Register a dynamic role resolver for each defined access group. 60 | */ 61 | setupRoleResolvers() { 62 | this.options.groupRoles.forEach(accessGroup => { 63 | this.setupRoleResolver(accessGroup) 64 | }) 65 | } 66 | 67 | /** 68 | * Add operation hooks to limit access. 69 | */ 70 | setupFilters() { 71 | const models = [ this.options.groupModel ].concat(this.getGroupContentModels()) 72 | 73 | models.forEach(modelName => { 74 | const Model = this.app.models[modelName] 75 | 76 | if (typeof Model.observe === 'function') { 77 | debug('Attaching access observer to %s', modelName) 78 | Model.observe('access', (ctx, next) => { 79 | const currentUser = this.getCurrentUser() 80 | 81 | if (currentUser) { 82 | // Do not filter if options.skipAccess has been set. 83 | if (ctx.options.skipAccess) { 84 | debug('skipAccess: true - skipping access filters') 85 | return next() 86 | } 87 | 88 | // Do not filter if the request is being made against a single model instance. 89 | if (_get(ctx.query, 'where.id')) { 90 | debug('looking up by Id - skipping access filters') 91 | return next() 92 | } 93 | 94 | // Do not apply filters if no group access acls were applied. 95 | const loopbackContext = LoopBackContext.getCurrentContext({ bind: true }) 96 | const groupAccessApplied = Boolean(loopbackContext && loopbackContext.get('groupAccessApplied')) 97 | 98 | if (!groupAccessApplied) { 99 | debug('acls not appled - skipping access filters') 100 | return next() 101 | } 102 | 103 | debug('%s observe access: query=%s, options=%o, hookState=%o', 104 | Model.modelName, JSON.stringify(ctx.query, null, 4), ctx.options, ctx.hookState) 105 | 106 | return this.buildFilter(currentUser.getId(), ctx.Model) 107 | .then(filter => { 108 | debug('original query: %o', JSON.stringify(ctx.query, null, 4)) 109 | const where = ctx.query.where ? { 110 | and: [ ctx.query.where, filter ], 111 | } : filter 112 | 113 | ctx.query.where = where 114 | debug('modified query: %s', JSON.stringify(ctx.query, null, 4)) 115 | }) 116 | } 117 | return next() 118 | }) 119 | } 120 | }) 121 | } 122 | 123 | /** 124 | * Build a where filter to restrict search results to a users group 125 | * 126 | * @param {String} userId UserId to build filter for. 127 | * @param {Object} Model Model to build filter for, 128 | * @returns {Object} A where filter. 129 | */ 130 | buildFilter(userId, Model) { 131 | const filter = { } 132 | const key = this.isGroupModel(Model) ? Model.getIdName() : this.options.foreignKey 133 | // TODO: Support key determination based on the belongsTo relationship. 134 | 135 | return this.getUserGroups(userId) 136 | .then(userGroups => { 137 | userGroups = Array.from(userGroups, group => group[this.options.foreignKey]) 138 | filter[key] = { inq: userGroups } 139 | return filter 140 | }) 141 | } 142 | 143 | /** 144 | * Check if a model class is the configured group model. 145 | * 146 | * @param {String|Object} modelClass Model class to check. 147 | * @returns {Boolean} Returns true if the principalId is on the expected format. 148 | */ 149 | isGroupModel(modelClass) { 150 | if (modelClass) { 151 | const groupModel = this.app.models[this.options.groupModel] 152 | 153 | return modelClass === groupModel || 154 | modelClass.prototype instanceof groupModel || 155 | modelClass === this.options.groupModel 156 | } 157 | return false 158 | } 159 | 160 | /** 161 | * Check if a model class is the configured group access model. 162 | * 163 | * @param {String|Object} modelClass Model class to check. 164 | * @returns {Boolean} Returns true if the principalId is on the expected format. 165 | */ 166 | isGroupAccessModel(modelClass) { 167 | if (modelClass) { 168 | const groupAccessModel = this.app.models[this.options.groupAccessModel] 169 | 170 | return modelClass === groupAccessModel || 171 | modelClass.prototype instanceof groupAccessModel || 172 | modelClass === this.options.groupAccessModel 173 | } 174 | return false 175 | } 176 | 177 | /** 178 | * Get a list of group content models (models that have a belongs to relationship to the group model) 179 | * 180 | * @returns {Array} Returns a list of group content models. 181 | */ 182 | getGroupContentModels() { 183 | const models = [ ] 184 | 185 | Object.keys(this.app.models).forEach(modelName => { 186 | const modelClass = this.app.models[modelName] 187 | 188 | // Mark the group itself as a group or the group access model. 189 | if (this.isGroupModel(modelClass) || this.isGroupAccessModel(modelClass)) { 190 | return 191 | } 192 | 193 | // Try to follow belongsTo 194 | for (let rel in modelClass.relations) { 195 | rel = _get(modelClass, `relations.${rel}`) 196 | // debug('Checking relation %s to %s: %j', r, rel.modelTo.modelName, rel); 197 | if (rel.type === 'belongsTo' && this.isGroupModel(rel.modelTo)) { 198 | models.push(modelName) 199 | } 200 | } 201 | }) 202 | 203 | debug('Got group content models: %o', models) 204 | return models 205 | } 206 | 207 | /** 208 | * Get the access groups for a given user. 209 | * 210 | * @param {String} userId UserId to fetch access groups for. 211 | * @param {Boolean} force Boolean indicating wether to bypass the cache if it exists. 212 | * @param {Function} [cb] A callback function. 213 | * @returns {Boolean} Returns true if the principalId is on the expected format. 214 | */ 215 | getUserGroups(userId, force, cb) { 216 | force = force || false 217 | cb = cb || createPromiseCallback() 218 | const currentUser = this.getCurrentUser() 219 | const currentUserGroups = this.getCurrentUserGroups() 220 | 221 | // Return from the context cache if exists. 222 | if (!force && currentUser && currentUser.getId() === userId) { 223 | debug('getUserGroups returning from cache: %o', currentUserGroups) 224 | process.nextTick(() => cb(null, currentUserGroups)) 225 | return cb.promise 226 | } 227 | 228 | // Otherwise lookup from the datastore. 229 | this.app.models[this.options.groupAccessModel].find({ 230 | where: { 231 | userId, 232 | }, 233 | }) 234 | .then(groups => { 235 | debug('getUserGroups returning from datastore: %o', currentUserGroups) 236 | cb(null, groups) 237 | }) 238 | .catch(cb) 239 | 240 | return cb.promise 241 | } 242 | 243 | /** 244 | * Get the currently logged in user. 245 | * 246 | * @returns {Object} Returns the currently logged in user. 247 | */ 248 | getCurrentUser() { 249 | const ctx = LoopBackContext.getCurrentContext({ bind: true }) 250 | const currentUser = (ctx && ctx.get('currentUser')) || null 251 | 252 | return currentUser 253 | } 254 | 255 | /** 256 | * Get the currently logged in user's access groups from the current request cache. 257 | * 258 | * @returns {Array} Returnds a list of access groups the user is a member of. 259 | */ 260 | getCurrentUserGroups() { 261 | const ctx = LoopBackContext.getCurrentContext({ bind: true }) 262 | const currentUserGroups = (ctx && ctx.get('currentUserGroups')) || [] 263 | 264 | return currentUserGroups 265 | } 266 | 267 | /** 268 | * Valid that a principalId conforms to the expected format. 269 | * 270 | * @param {String} principalId A principalId. 271 | * @returns {Boolean} Returns true if the principalId is on the expected format. 272 | */ 273 | isValidPrincipalId(principalId) { 274 | return Boolean(this.extractRoleName(principalId)) 275 | } 276 | 277 | /** 278 | * Extract the role name from a principalId (eg, for '$group:admin' the role name is 'admin'). 279 | * 280 | * @param {String} principalId A principalId. 281 | * @returns {Boolean} Returns true if the principalId is on the expected format. 282 | */ 283 | extractRoleName(principalId) { 284 | return principalId.split(':')[1] 285 | } 286 | 287 | /** 288 | * Register a dynamic role resolver for an access group. 289 | * 290 | * @param {String} accessGroup Name of the access group to be setup. 291 | */ 292 | setupRoleResolver(accessGroup) { 293 | debug(`Registering role resolver for ${accessGroup}`) 294 | const Role = this.app.models[this.options.roleModel] 295 | 296 | Role.registerResolver(accessGroup, (role, context, cb) => { 297 | cb = cb || createPromiseCallback() 298 | const modelClass = context.model 299 | const { modelId } = context 300 | const userId = context.getUserId() 301 | const roleName = this.extractRoleName(role) 302 | const GroupAccess = this.app.models[this.options.groupAccessModel] 303 | const scope = { } 304 | 305 | debug(`Role resolver for ${role}: evaluate ${modelClass.modelName} with id: ${modelId} for user: ${userId}`) 306 | 307 | // No userId is present 308 | if (!userId) { 309 | process.nextTick(() => { 310 | debug('Deny access for anonymous user') 311 | cb(null, false) 312 | }) 313 | return cb.promise 314 | } 315 | 316 | LoopBackContext.getCurrentContext({ bind: true }).set('groupAccessApplied', true) 317 | 318 | /** 319 | * Basic application that does not cover static methods. Similar to $owner. (RECOMMENDED) 320 | */ 321 | if (!this.options.applyToStatic) { 322 | if (!context || !modelClass || !modelId) { 323 | process.nextTick(() => { 324 | debug('Deny access (context: %s, context.model: %s, context.modelId: %s)', 325 | Boolean(context), Boolean(modelClass), Boolean(modelId)) 326 | cb(null, false) 327 | }) 328 | return cb.promise 329 | } 330 | 331 | this.isGroupMemberWithRole(modelClass, modelId, userId, roleName) 332 | .then(res => cb(null, res)) 333 | .catch(cb) 334 | 335 | return cb.promise 336 | } 337 | 338 | /** 339 | * More complex application that also covers static methods. (EXPERIMENTAL) 340 | */ 341 | Promise.join(this.getCurrentGroupId(context), this.getTargetGroupId(context), 342 | (currentGroupId, targetGroupId) => { 343 | if (!currentGroupId) { 344 | // TODO: Use promise cancellation to abort the chain early. 345 | // Causes the access check to be bypassed (see below). 346 | return [ false ] 347 | } 348 | 349 | scope.currentGroupId = currentGroupId 350 | scope.targetGroupId = targetGroupId 351 | const actions = [ ] 352 | const conditions = { userId, role: roleName } 353 | 354 | conditions[this.options.foreignKey] = currentGroupId 355 | actions.push(GroupAccess.count(conditions)) 356 | 357 | // If this is an attempt to save the item into a new group, check the user has access to the target group. 358 | if (targetGroupId && targetGroupId !== currentGroupId) { 359 | conditions[this.options.foreignKey] = targetGroupId 360 | actions.push(GroupAccess.count(conditions)) 361 | } 362 | 363 | return actions 364 | }) 365 | .spread((currentGroupCount, targetGroupCount) => { 366 | let res = false 367 | 368 | if (currentGroupCount === false) { 369 | // No group context was determined, so allow passthrough access. 370 | res = true 371 | } 372 | else { 373 | // Determine grant based on the current/target group context. 374 | res = currentGroupCount > 0 375 | 376 | debug(`user ${userId} ${res ? 'is a' : 'is not a'} ${roleName} of group ${scope.currentGroupId}`) 377 | 378 | // If it's an attempt to save into a new group, also ensure the user has access to the target group. 379 | if (scope.targetGroupId && scope.targetGroupId !== scope.currentGroupId) { 380 | const tMember = targetGroupCount > 0 381 | 382 | debug(`user ${userId} ${tMember ? 'is a' : 'is not a'} ${roleName} of group ${scope.targetGroupId}`) 383 | res = res && tMember 384 | } 385 | } 386 | 387 | // Note the fact that we are allowing access due to passing an ACL. 388 | if (res) { 389 | LoopBackContext.getCurrentContext({ bind: true }).set('groupAccessApplied', true) 390 | } 391 | 392 | return cb(null, res) 393 | }) 394 | .catch(cb) 395 | return cb.promise 396 | }) 397 | } 398 | 399 | /** 400 | * Check if a given user ID has a given role in the model instances group. 401 | * @param {Function} modelClass The model class 402 | * @param {*} modelId The model ID 403 | * @param {*} userId The user ID 404 | * @param {*} roleId The role ID 405 | * @param {Function} callback Callback function 406 | */ 407 | isGroupMemberWithRole(modelClass, modelId, userId, roleId, cb) { 408 | cb = cb || createPromiseCallback() 409 | debug('isGroupMemberWithRole: modelClass: %o, modelId: %o, userId: %o, roleId: %o', 410 | modelClass && modelClass.modelName, modelId, userId, roleId) 411 | 412 | // No userId is present 413 | if (!userId) { 414 | process.nextTick(() => { 415 | cb(null, false) 416 | }) 417 | return cb.promise 418 | } 419 | 420 | // Is the modelClass GroupModel or a subclass of GroupModel? 421 | if (this.isGroupModel(modelClass)) { 422 | debug('Access to Group Model %s attempted', modelId) 423 | this.hasRoleInGroup(userId, roleId, modelId) 424 | .then(res => cb(null, res)) 425 | return cb.promise 426 | } 427 | 428 | modelClass.findById(modelId, (err, inst) => { 429 | if (err || !inst) { 430 | debug('Model not found for id %j', modelId) 431 | return cb(err, false) 432 | } 433 | debug('Model found: %j', inst) 434 | const groupId = inst[this.options.foreignKey] 435 | 436 | // Ensure groupId exists and is not a function/relation 437 | if (groupId && typeof groupId !== 'function') { 438 | return this.hasRoleInGroup(userId, roleId, groupId) 439 | .then(res => cb(null, res)) 440 | } 441 | // Try to follow belongsTo 442 | for (const relName in modelClass.relations) { 443 | const rel = modelClass.relations[relName] 444 | 445 | if (rel.type === 'belongsTo' && this.isGroupModel(rel.modelTo)) { 446 | debug('Checking relation %s to %s: %j', relName, rel.modelTo.modelName, rel) 447 | return inst[relName](function processRelatedGroup(error, group) { 448 | if (!error && group) { 449 | debug('Group found: %j', group.getId()) 450 | return cb(null, this.hasRoleInGroup(userId, roleId, group.getId())) 451 | } 452 | return cb(error, false) 453 | }) 454 | } 455 | } 456 | debug('No matching belongsTo relation found for model %j and group: %j', modelId, groupId) 457 | return cb(null, false) 458 | }) 459 | return cb.promise 460 | } 461 | 462 | hasRoleInGroup(userId, role, group, cb) { 463 | debug('hasRoleInGroup: role: %o, group: %o, userId: %o', role, group, userId) 464 | cb = cb || createPromiseCallback() 465 | const GroupAccess = this.app.models[this.options.groupAccessModel] 466 | const conditions = { userId, role } 467 | 468 | conditions[this.options.foreignKey] = group 469 | GroupAccess.count(conditions) 470 | .then(count => { 471 | const res = count > 0 472 | 473 | debug(`User ${userId} ${res ? 'HAS' : 'DOESNT HAVE'} ${role} role in group ${group}`) 474 | cb(null, res) 475 | }) 476 | return cb.promise 477 | } 478 | 479 | /** 480 | * Determine the current Group Id based on the current security context. 481 | * 482 | * @param {Object} context The security context. 483 | * @param {function} [cb] A callback function. 484 | * @returns {Object} Returns the determined Group ID. 485 | */ 486 | getCurrentGroupId(context, cb) { 487 | cb = cb || createPromiseCallback() 488 | debug('getCurrentGroupId context.remotingContext.args: %o', context.remotingContext.args) 489 | let groupId = null 490 | 491 | // If we are accessing the group model directly, the group id is the model id. 492 | if (this.isGroupModel(context.model)) { 493 | process.nextTick(() => cb(null, context.modelId)) 494 | return cb.promise 495 | } 496 | 497 | // If we are accessing an existing model, get the group id from the existing model instance. 498 | // TODO: Cache this result so that it can be reused across each ACL lookup attempt. 499 | if (context.modelId) { 500 | debug(`fetching group id for existing model with id: ${context.modelId}`) 501 | context.model.findById(context.modelId, { }, { 502 | skipAccess: true, 503 | }) 504 | .then(item => { 505 | // TODO: Attempt to follow relationships in addition to the foreign key. 506 | if (item) { 507 | debug(`determined group id ${item[this.options.foreignKey]} from existing model ${context.modelId}`) 508 | groupId = item[this.options.foreignKey] 509 | } 510 | cb(null, groupId) 511 | }) 512 | .catch(cb) 513 | } 514 | 515 | // If we are creating a new model, get the foreignKey from the incoming data. 516 | else if (_get(context, `remotingContext.args.data[${this.options.foreignKey}]`)) { 517 | debug(`determined current group id ${groupId} from incoming data`) 518 | groupId = context.remotingContext.args.data[this.options.foreignKey] 519 | process.nextTick(() => cb(null, groupId)) 520 | } 521 | 522 | // Otherwise, return null. 523 | else { 524 | debug('unable to determine current group context') 525 | process.nextTick(() => cb(null, groupId)) 526 | } 527 | 528 | return cb.promise 529 | } 530 | 531 | /** 532 | * Determine the target Group Id based on the current security context. 533 | * 534 | * @param {Object} context The security context. 535 | * @param {function} [cb] A callback function. 536 | * @returns {Object} Returns the determined Group ID. 537 | */ 538 | getTargetGroupId(context, cb) { 539 | cb = cb || createPromiseCallback() 540 | debug('getTargetGroupId context.remotingContext.args: %o', context.remotingContext.args) 541 | let groupId = null 542 | 543 | // Get the target group id from the incoming data. 544 | if (_get(context, `remotingContext.args.data[${this.options.foreignKey}]`)) { 545 | debug(`determined target group id ${groupId} from incoming data`) 546 | groupId = context.remotingContext.args.data[this.options.foreignKey] 547 | } 548 | 549 | // Otherwise, return null. 550 | else { 551 | debug('unable to determine target group context') 552 | } 553 | 554 | process.nextTick(() => cb(null, groupId)) 555 | 556 | return cb.promise 557 | } 558 | } 559 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "loopback-component-access-groups", 3 | "description": "Access controls for Loopback.", 4 | "version": "0.0.0-development", 5 | "author": { 6 | "name": "Tom Kirkpatrick @mrfelton" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/fullcube/loopback-component-access-groups.git" 11 | }, 12 | "keywords": [ 13 | "loopback", 14 | "strongloop", 15 | "access" 16 | ], 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/fullcube/loopback-component-access-groups/issues" 20 | }, 21 | "homepage": "https://github.com/fullcube/loopback-component-access-groups", 22 | "directories": { 23 | "lib": "lib", 24 | "test": "test" 25 | }, 26 | "main": "./lib/index.js", 27 | "scripts": { 28 | "lint": "eslint './{lib,test}/**/*.js'", 29 | "test": "NODE_ENV=test nyc --reporter=lcov --reporter=text --reporter=text-summary mocha test/*test.js", 30 | "pretest": "npm run lint", 31 | "coverage": "nyc report --reporter=text-lcov | coveralls", 32 | "simple-app": "DEBUG=loopback:component:access* node test/fixtures/simple-app/server/server.js", 33 | "semantic-release": "semantic-release pre && npm publish && semantic-release post" 34 | }, 35 | "dependencies": { 36 | "bluebird": "^3.5.0", 37 | "debug": "^2.6.8", 38 | "lodash": "^4.17.4" 39 | }, 40 | "peerDependencies": { 41 | "loopback-context": "^3.1.0", 42 | "cls-hooked": "^4.1.5" 43 | }, 44 | "devDependencies": { 45 | "@bubltechnology/customizable-commit-analyzer": "^1.0.2-0", 46 | "chai": "^4.0.2", 47 | "chai-tbd": "0.1.0", 48 | "condition-circle": "^1.5.0", 49 | "coveralls": "latest", 50 | "dirty-chai": "2.0.1", 51 | "eslint": "^4.1.0", 52 | "eslint-config-fullcube": "^3.0.0", 53 | "loopback": "^3.4.0", 54 | "loopback-boot": "^2.24.0", 55 | "loopback-component-explorer": "2.3.0", 56 | "loopback-context": "^3.1.0", 57 | "cls-hooked": "^4.1.5", 58 | "loopback-component-fixtures": "^1.1.0", 59 | "mocha": "^3.4.1", 60 | "mocha-sinon": "latest", 61 | "nyc": "latest", 62 | "sinon": "latest", 63 | "sinon-chai": "latest", 64 | "strong-error-handler": "^2.1.0", 65 | "supertest": "^3.0.0", 66 | "semantic-release": "^6.3.6" 67 | }, 68 | "config": { 69 | "commitTypeMap": { 70 | "feat": "minor", 71 | "fix": "patch", 72 | "docs": "patch", 73 | "style": "patch", 74 | "refactor": "patch", 75 | "perf": "patch", 76 | "test": "patch", 77 | "build": "patch", 78 | "ci": "patch", 79 | "chore": "patch", 80 | "revert": "patch" 81 | } 82 | }, 83 | "release": { 84 | "verifyConditions": "condition-circle", 85 | "analyzeCommits": "@bubltechnology/customizable-commit-analyzer" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": 'fullcube/mocha', 3 | "rules": { 4 | "global-require": 0 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/common/models/access-token.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "accessToken", 3 | "plural": "accessTokens", 4 | "base": "AccessToken", 5 | "relations": { 6 | "user": { 7 | "type": "belongsTo", 8 | "model": "user", 9 | "foreignKey": "userId" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/common/models/invoice.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Invoice", 3 | "base": "PersistedModel", 4 | "options": { 5 | "idInjection": true, 6 | "validateUpsert": true, 7 | "forceId": false, 8 | "replaceOnPUT": false 9 | }, 10 | "properties": { 11 | "storeId": { 12 | "type": "string", 13 | "required": true 14 | }, 15 | "invoiceNumber": { 16 | "type": "number", 17 | "required": true 18 | } 19 | }, 20 | "validations": [], 21 | "relations": { 22 | "Store": { 23 | "type": "belongsTo", 24 | "model": "Store", 25 | "foreignKey": "storeId" 26 | }, 27 | "transactions": { 28 | "type": "hasMany", 29 | "model": "Transaction", 30 | "foreignKey": "invoiceId" 31 | } 32 | }, 33 | "acls": [ 34 | { 35 | "accessType": "*", 36 | "principalType": "ROLE", 37 | "principalId": "$everyone", 38 | "permission": "DENY" 39 | }, 40 | { 41 | "accessType": "READ", 42 | "principalType": "ROLE", 43 | "principalId": "$group:member", 44 | "permission": "ALLOW" 45 | }, 46 | { 47 | "accessType": "READ", 48 | "principalType": "ROLE", 49 | "principalId": "$group:manager", 50 | "permission": "ALLOW" 51 | }, 52 | { 53 | "accessType": "WRITE", 54 | "principalType": "ROLE", 55 | "principalId": "$group:manager", 56 | "permission": "ALLOW", 57 | "property": "create" 58 | }, 59 | { 60 | "accessType": "WRITE", 61 | "principalType": "ROLE", 62 | "principalId": "$group:manager", 63 | "permission": "ALLOW", 64 | "property": ["patchOrCreate", "replaceOrCreate", "replaceById", "updateOrCreate", "updateAttributes", "patchAttributes"] 65 | }, 66 | { 67 | "accessType": "*", 68 | "principalType": "ROLE", 69 | "principalId": "$group:admin", 70 | "permission": "ALLOW" 71 | } 72 | ], 73 | "methods": {} 74 | } 75 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/common/models/store.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Store", 3 | "base": "PersistedModel", 4 | "options": { 5 | "idInjection": true, 6 | "validateUpsert": true, 7 | "forceId": false, 8 | "replaceOnPUT": false 9 | }, 10 | "properties": { 11 | "id": { 12 | "type": "string", 13 | "id": true, 14 | "required": true 15 | }, 16 | "name": { 17 | "type": "string", 18 | "required": true 19 | } 20 | }, 21 | "validations": [], 22 | "relations": { 23 | "users": { 24 | "type": "hasMany", 25 | "model": "user", 26 | "foreignKey": "storeId", 27 | "through": "Team" 28 | }, 29 | "invoices": { 30 | "type": "hasMany", 31 | "model": "Invoice", 32 | "foreignKey": "storeId" 33 | }, 34 | "transactions": { 35 | "type": "hasMany", 36 | "model": "Transaction", 37 | "foreignKey": "storeId" 38 | } 39 | }, 40 | "acls": [ 41 | { 42 | "accessType": "*", 43 | "principalType": "ROLE", 44 | "principalId": "$everyone", 45 | "permission": "DENY" 46 | }, 47 | { 48 | "accessType": "READ", 49 | "principalType": "ROLE", 50 | "principalId": "$group:member", 51 | "permission": "ALLOW" 52 | }, 53 | { 54 | "accessType": "READ", 55 | "principalType": "ROLE", 56 | "principalId": "$group:manager", 57 | "permission": "ALLOW" 58 | }, 59 | { 60 | "accessType": "WRITE", 61 | "principalType": "ROLE", 62 | "principalId": "$group:manager", 63 | "permission": "ALLOW", 64 | "property": "create" 65 | }, 66 | { 67 | "accessType": "WRITE", 68 | "principalType": "ROLE", 69 | "principalId": "$group:manager", 70 | "permission": "ALLOW", 71 | "property": "updateAttributes" 72 | }, 73 | { 74 | "accessType": "WRITE", 75 | "principalType": "ROLE", 76 | "principalId": "$group:manager", 77 | "permission": "ALLOW", 78 | "property": "upsert" 79 | }, 80 | { 81 | "accessType": "*", 82 | "principalType": "ROLE", 83 | "principalId": "$group:admin", 84 | "permission": "ALLOW" 85 | } 86 | ], 87 | "methods": {} 88 | } 89 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/common/models/team.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Team", 3 | "base": "PersistedModel", 4 | "options": { 5 | "idInjection": true, 6 | "validateUpsert": true, 7 | "forceId": false, 8 | "replaceOnPUT": false 9 | }, 10 | "properties": { 11 | "storeId": { 12 | "type": "string", 13 | "required": true 14 | }, 15 | "userId": { 16 | "type": "string", 17 | "required": true 18 | }, 19 | "role": { 20 | "type": "string", 21 | "required": true 22 | } 23 | }, 24 | "validations": [], 25 | "relations": { 26 | "store": { 27 | "type": "belongsTo", 28 | "model": "Store", 29 | "foreignKey": "storeId" 30 | }, 31 | "user": { 32 | "type": "belongsTo", 33 | "model": "user", 34 | "foreignKey": "userId" 35 | } 36 | }, 37 | "acls": [], 38 | "methods": {} 39 | } 40 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/common/models/transaction.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Transaction", 3 | "base": "PersistedModel", 4 | "options": { 5 | "idInjection": true, 6 | "validateUpsert": true, 7 | "forceId": false, 8 | "replaceOnPUT": false 9 | }, 10 | "properties": { 11 | "storeId": { 12 | "type": "string", 13 | "required": true 14 | }, 15 | "invoiceId": { 16 | "type": "string", 17 | "required": true 18 | }, 19 | "name": { 20 | "type": "string", 21 | "required": true 22 | } 23 | }, 24 | "validations": [], 25 | "relations": { 26 | "Store": { 27 | "type": "belongsTo", 28 | "model": "Store", 29 | "foreignKey": "storeId" 30 | }, 31 | "Invoice": { 32 | "type": "belongsTo", 33 | "model": "Invoice", 34 | "foreignKey": "invoiceId" 35 | } 36 | }, 37 | "acls": [ 38 | { 39 | "accessType": "*", 40 | "principalType": "ROLE", 41 | "principalId": "$everyone", 42 | "permission": "DENY" 43 | }, 44 | { 45 | "accessType": "READ", 46 | "principalType": "ROLE", 47 | "principalId": "$group:member", 48 | "permission": "ALLOW" 49 | }, 50 | { 51 | "accessType": "READ", 52 | "principalType": "ROLE", 53 | "principalId": "$group:manager", 54 | "permission": "ALLOW" 55 | }, 56 | { 57 | "accessType": "WRITE", 58 | "principalType": "ROLE", 59 | "principalId": "$group:manager", 60 | "permission": "ALLOW", 61 | "property": "create" 62 | }, 63 | { 64 | "accessType": "WRITE", 65 | "principalType": "ROLE", 66 | "principalId": "$group:manager", 67 | "permission": "ALLOW", 68 | "property": "updateAttributes" 69 | }, 70 | { 71 | "accessType": "WRITE", 72 | "principalType": "ROLE", 73 | "principalId": "$group:manager", 74 | "permission": "ALLOW", 75 | "property": "upsert" 76 | }, 77 | { 78 | "accessType": "*", 79 | "principalType": "ROLE", 80 | "principalId": "$group:admin", 81 | "permission": "ALLOW" 82 | } 83 | ], 84 | "methods": {} 85 | } 86 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/common/models/user.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = function userCustomizer(user) { 4 | user.currentUser = function(cb) { 5 | return process.nextTick(() => cb(null, user.getCurrentUser())) 6 | } 7 | 8 | } 9 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/common/models/user.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "user", 3 | "base": "User", 4 | "options": { 5 | "idInjection": true, 6 | "validateUpsert": true, 7 | "forceId": false, 8 | "saltWorkFactor": 1, 9 | "replaceOnPUT": false 10 | }, 11 | "mixins": { 12 | "GetCurrentUser": true 13 | }, 14 | "properties": { 15 | "id": { 16 | "type": "string", 17 | "id": true, 18 | "required": true 19 | } 20 | }, 21 | "validations": [], 22 | "relations": { 23 | "accessTokens": { 24 | "type": "hasMany", 25 | "model": "accessToken", 26 | "foreignKey": "userId", 27 | "options": { 28 | "disableInclude": true 29 | } 30 | }, 31 | "stores": { 32 | "type": "hasMany", 33 | "model": "Store", 34 | "foreignKey": "userId", 35 | "through": "Team" 36 | } 37 | }, 38 | "acls": [ 39 | { 40 | "accessType": "*", 41 | "principalType": "ROLE", 42 | "principalId": "$everyone", 43 | "permission": "DENY" 44 | }, 45 | { 46 | "principalType": "ROLE", 47 | "principalId": "$everyone", 48 | "permission": "DENY", 49 | "property": "create" 50 | }, 51 | { 52 | "accessType": "*", 53 | "principalType": "ROLE", 54 | "principalId": "$owner", 55 | "permission": "ALLOW" 56 | }, 57 | { 58 | "accessType": "*", 59 | "principalType": "ROLE", 60 | "principalId": "$everyone", 61 | "permission": "ALLOW", 62 | "property": "currentUser" 63 | } 64 | ], 65 | "methods": { 66 | "currentUser": { 67 | "description": "Get the current user.", 68 | "http": { 69 | "path": "/currentUser", 70 | "verb": "get" 71 | }, 72 | "returns": { 73 | "arg": "currentUser", 74 | "type": "Object", 75 | "root": true 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/fixtures/AccessToken.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "id": "tokenAdmin", 3 | "userId": "admin" 4 | }, { 5 | "id": "tokenUser", 6 | "userId": "generalUser" 7 | }, { 8 | "id": "tokenAdminA", 9 | "userId": "storeAdminA" 10 | }, { 11 | "id": "tokenManagerA", 12 | "userId": "storeManagerA" 13 | }, { 14 | "id": "tokenMemberA", 15 | "userId": "storeMemberA" 16 | }, { 17 | "id": "tokenAdminB", 18 | "userId": "storeAdminB" 19 | }, { 20 | "id": "tokenManagerB", 21 | "userId": "storeManagerB" 22 | }, { 23 | "id": "tokenMemberB", 24 | "userId": "storeMemberB" 25 | }] 26 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/fixtures/Invoice.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "id": "1", 3 | "invoiceNumber": 1, 4 | "storeId": "A", 5 | "status": "active" 6 | }, { 7 | "id": "2", 8 | "invoiceNumber": 2, 9 | "storeId": "B", 10 | "status": "active" 11 | }, { 12 | "id": "3", 13 | "invoiceNumber": 3, 14 | "storeId": "A", 15 | "status": "disabled" 16 | }, { 17 | "id": "4", 18 | "invoiceNumber": 4, 19 | "storeId": "B", 20 | "status": "disabled" 21 | }] 22 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/fixtures/Store.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "id": "A", 3 | "name": "Store A" 4 | }, { 5 | "id": "B", 6 | "name": "Store B" 7 | }] 8 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/fixtures/Team.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "storeId": "A", 3 | "userId": "storeAdminA", 4 | "role": "admin" 5 | }, { 6 | "storeId": "A", 7 | "userId": "storeManagerA", 8 | "role": "manager" 9 | }, { 10 | "storeId": "A", 11 | "userId": "storeMemberA", 12 | "role": "member" 13 | }, { 14 | "storeId": "B", 15 | "userId": "storeAdminB", 16 | "role": "admin" 17 | }, { 18 | "storeId": "B", 19 | "userId": "storeManagerB", 20 | "role": "manager" 21 | }, { 22 | "storeId": "B", 23 | "userId": "storeMemberB", 24 | "role": "member" 25 | }] 26 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/fixtures/Transaction.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "id": "1", 3 | "name": "Transaction 1 (inv1)", 4 | "storeId": "A", 5 | "invoiceId": "1" 6 | }, { 7 | "id": "2", 8 | "name": "Transaction 2 (inv1)", 9 | "storeId": "A", 10 | "invoiceId": "1" 11 | }, { 12 | "id": "3", 13 | "name": "Transaction 3 (inv2)", 14 | "storeId": "B", 15 | "invoiceId": "2" 16 | }, { 17 | "id": "4", 18 | "name": "Transaction 4 (inv2)", 19 | "storeId": "B", 20 | "invoiceId": "2" 21 | }] 22 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/fixtures/user.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "id": "admin", 3 | "username": "admin", 4 | "email": "admin@fullcube.com", 5 | "password": "password" 6 | }, { 7 | "id": "generalUser", 8 | "username": "generalUser", 9 | "email": "generalUser@fullcube.com", 10 | "password": "password" 11 | }, { 12 | "id": "storeAdminA", 13 | "username": "storeAdminA", 14 | "email": "storeAdminA@fullcube.com", 15 | "password": "password" 16 | }, { 17 | "id": "storeManagerA", 18 | "username": "storeManagerA", 19 | "email": "storeManagerA@fullcube.com", 20 | "password": "password" 21 | }, { 22 | "id": "storeMemberA", 23 | "username": "storeMemberA", 24 | "email": "storeMemberA@fullcube.com", 25 | "password": "password" 26 | }, { 27 | "id": "storeAdminB", 28 | "username": "storeAdminB", 29 | "email": "storeAdminB@fullcube.com", 30 | "password": "password" 31 | }, { 32 | "id": "storeManagerB", 33 | "username": "storeManagerB", 34 | "email": "storeManagerB@fullcube.com", 35 | "password": "password" 36 | }, { 37 | "id": "storeMemberB", 38 | "username": "storeMemberB", 39 | "email": "storeMemberB@fullcube.com", 40 | "password": "password" 41 | }] 42 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/boot/authentication.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = function enableAuthentication(app) { 4 | // enable authentication 5 | app.enableAuth() 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/boot/root.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = function(app) { 4 | // Install a `/` route that returns app status 5 | const router = app.loopback.Router() 6 | 7 | router.get('/', app.loopback.status()) 8 | app.use(router) 9 | } 10 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/component-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "loopback-component-fixtures": { 3 | "loadFixturesOnStartup": true, 4 | "environments": [ "development", "test" ], 5 | "fixturesPath": "/test/fixtures/simple-app/fixtures/" 6 | }, 7 | "../../../../lib": { 8 | "userModel": "user", 9 | "roleModel": "Role", 10 | "groupAccessModel": "Team", 11 | "groupModel": "Store", 12 | "foreignKey": "storeId", 13 | "groupRoles": [ 14 | "$group:admin", 15 | "$group:manager", 16 | "$group:member" 17 | ], 18 | "applyToStatic": true 19 | }, 20 | "loopback-component-explorer": { 21 | "mountPath": "/explorer" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "restApiRoot": "/api", 3 | "host": "0.0.0.0", 4 | "port": 3000, 5 | "remoting": { 6 | "context": false, 7 | "rest": { 8 | "normalizeHttpPath": false, 9 | "xml": false 10 | }, 11 | "json": { 12 | "strict": false, 13 | "limit": "100kb" 14 | }, 15 | "urlencoded": { 16 | "extended": true, 17 | "limit": "100kb" 18 | }, 19 | "cors": false 20 | }, 21 | "legacyExplorer": false, 22 | "logoutSessionsOnSensitiveChanges": true 23 | } 24 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/datasources.json: -------------------------------------------------------------------------------- 1 | { 2 | "db": { 3 | "name": "db", 4 | "connector": "memory" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/middleware.development.json: -------------------------------------------------------------------------------- 1 | { 2 | "final:after": { 3 | "strong-error-handler": { 4 | "params": { 5 | "debug": true, 6 | "log": true 7 | } 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/middleware.json: -------------------------------------------------------------------------------- 1 | { 2 | "initial:before": { 3 | "loopback#token": { 4 | "params": { 5 | "model": "accessToken" 6 | } 7 | } 8 | }, 9 | "initial": { 10 | "loopback-context#per-request": { 11 | "params": { 12 | "enableHttpContext": true 13 | } 14 | } 15 | }, 16 | "session": {}, 17 | "auth": {}, 18 | "parse": {}, 19 | "routes": { 20 | "loopback#rest": { 21 | "paths": [ 22 | "${restApiRoot}" 23 | ] 24 | } 25 | }, 26 | "files": {}, 27 | "final": { 28 | "loopback#urlNotFound": {} 29 | }, 30 | "final:after": { 31 | "strong-error-handler": {} 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/model-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "sources": [ 4 | "loopback/common/models", 5 | "loopback/server/models", 6 | "../common/models", 7 | "./models" 8 | ], 9 | "mixins": [ 10 | "loopback/common/mixins", 11 | "loopback/server/mixins", 12 | "../common/mixins", 13 | "./mixins", 14 | "../../../../lib/mixins" 15 | ] 16 | }, 17 | 18 | "accessToken": { 19 | "dataSource": "db", 20 | "public": true 21 | }, 22 | "user": { 23 | "dataSource": "db", 24 | "public": true 25 | }, 26 | 27 | "ACL": { 28 | "dataSource": "db", 29 | "public": true 30 | }, 31 | "RoleMapping": { 32 | "dataSource": "db", 33 | "public": true 34 | }, 35 | "Role": { 36 | "dataSource": "db", 37 | "public": true 38 | }, 39 | "Store": { 40 | "dataSource": "db", 41 | "public": true 42 | }, 43 | "Team": { 44 | "dataSource": "db", 45 | "public": true 46 | }, 47 | "Invoice": { 48 | "dataSource": "db", 49 | "public": true 50 | }, 51 | "Transaction": { 52 | "dataSource": "db", 53 | "public": true 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/server.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 0 */ 2 | 3 | 'use strict' 4 | 5 | const loopback = require('loopback') 6 | const boot = require('loopback-boot') 7 | const app = loopback() 8 | 9 | app.start = function() { 10 | // start the web server 11 | return app.listen(function() { 12 | app.emit('started') 13 | 14 | const baseUrl = app.get('url').replace(/\/$/, '') 15 | 16 | console.log('Web server listening at: %s', baseUrl) 17 | if (app.get('loopback-component-explorer')) { 18 | const explorerPath = app.get('loopback-component-explorer').mountPath 19 | 20 | console.log('Browse your REST API at %s%s', baseUrl, explorerPath) 21 | } 22 | }) 23 | } 24 | 25 | // Bootstrap the application, configure models, datasources and middleware. 26 | // Sub-apps like REST API are mounted via boot scripts. 27 | boot(app, __dirname, function(err) { 28 | if (err) { 29 | throw err 30 | } 31 | 32 | // start the server if `$ node server.js` 33 | if (require.main === module) { 34 | app.start() 35 | } 36 | }) 37 | 38 | module.exports = app 39 | -------------------------------------------------------------------------------- /test/middleware-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const chai = require('chai') 5 | const LoopBackContext = require('loopback-context') 6 | 7 | const { expect } = chai 8 | 9 | chai.use(require('dirty-chai')) 10 | chai.use(require('sinon-chai')) 11 | 12 | require('mocha-sinon') 13 | 14 | const SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-app') 15 | const app = require(path.join(SIMPLE_APP, 'server/server.js')) 16 | 17 | describe('User Context Middleware', function() { 18 | describe('Without loopback context', function() { 19 | it('should return null', function() { 20 | const currentUser = app.models.user.getCurrentUser() 21 | 22 | expect(currentUser).to.be.null() 23 | }) 24 | }) 25 | 26 | describe('With user in loopback context', function() { 27 | it('should return the user', function() { 28 | LoopBackContext.runInContext(function() { 29 | const loopbackContext = LoopBackContext.getCurrentContext() 30 | const user = { 31 | id: 'generalUser', 32 | username: 'generalUser', 33 | password: '$2a$10$Hb5a4OK7ZK97zdziGLSYgOScOy2lRQi0Kd2RCkldxRk0hZo6Eemy6', 34 | email: 'generalUser@fullcube.com', 35 | } 36 | 37 | loopbackContext.set('currentUser', user) 38 | expect(app.models.user.getCurrentUser()).to.equal(user) 39 | }) 40 | }) 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /test/mixin-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const request = require('supertest') 5 | const chai = require('chai') 6 | const { expect } = chai 7 | 8 | chai.use(require('dirty-chai')) 9 | chai.use(require('sinon-chai')) 10 | 11 | require('mocha-sinon') 12 | 13 | const SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-app') 14 | const app = require(path.join(SIMPLE_APP, 'server/server.js')) 15 | 16 | function json(verb, url, data) { 17 | return request(app)[verb](url) 18 | .set('Content-Type', 'application/json') 19 | .set('Accept', 'application/json') 20 | .send(data) 21 | .expect('Content-Type', /json/) 22 | } 23 | 24 | /** 25 | * Initialise the application for the test suite. 26 | */ 27 | before(done => { 28 | function finishBoot() { 29 | return done() 30 | } 31 | if (app.booting) { 32 | return app.once('booted', finishBoot) 33 | } 34 | return finishBoot() 35 | }) 36 | 37 | describe('Current User Mixin.', function() { 38 | describe('Smoke test', function() { 39 | it('should add a getCurrentUser model method', function() { 40 | expect(app.models.user).itself.to.respondTo('getCurrentUser') 41 | }) 42 | }) 43 | 44 | describe('Role: unauthenticated', function() { 45 | it('should return null', function() { 46 | return json('get', '/api/users/currentUser') 47 | .expect(200) 48 | .then(res => { 49 | expect(res.body).to.be.null() 50 | }) 51 | }) 52 | }) 53 | 54 | describe('Role: authenticated', function() { 55 | it('should return the current user', function() { 56 | return json('post', '/api/users/login') 57 | .send({ username: 'generalUser', password: 'password' }) 58 | .expect(200) 59 | .then(res => json('get', `/api/users/currentUser?access_token=${res.body.id}`) 60 | .expect(200)) 61 | .then(res => { 62 | expect(res.body).to.be.an('object') 63 | expect(res.body).to.have.property('id', 'generalUser') 64 | expect(res.body).to.have.property('username', 'generalUser') 65 | expect(res.body).to.have.property('email', 'generalUser@fullcube.com') 66 | }) 67 | }) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /test/rest-test.js: -------------------------------------------------------------------------------- 1 | /* eslint max-nested-callbacks: 0 */ 2 | 3 | 'use strict' 4 | 5 | const path = require('path') 6 | const request = require('supertest') 7 | const chai = require('chai') 8 | const _includes = require('lodash').includes 9 | 10 | const { expect } = chai 11 | 12 | chai.use(require('dirty-chai')) 13 | chai.use(require('sinon-chai')) 14 | 15 | require('mocha-sinon') 16 | 17 | const SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-app') 18 | const app = require(path.join(SIMPLE_APP, 'server/server.js')) 19 | 20 | function json(verb, url) { 21 | return request(app)[verb](url) 22 | .set('Content-Type', 'application/json') 23 | .set('Accept', 'application/json') 24 | .expect('Content-Type', /json/) 25 | } 26 | 27 | /* --------------------- */ 28 | 29 | function logInAs(name) { 30 | return json('post', '/api/users/login') 31 | .send({ username: name, password: 'password' }) 32 | .expect(200) 33 | } 34 | 35 | /* --------------------- */ 36 | 37 | describe('REST API', function() { 38 | describe('Role: unauthenticated', function() { 39 | it('should not allow access to list invoices without access token', function() { 40 | return json('get', '/api/invoices') 41 | .expect(401) 42 | }) 43 | 44 | it('should not allow access to a teams invoice without access token', function() { 45 | return json('get', '/api/invoices/1') 46 | .expect(401) 47 | }) 48 | }) 49 | 50 | const users = [ 51 | { 52 | username: 'generalUser', 53 | abilities: [], 54 | }, { 55 | username: 'storeMemberA', 56 | abilities: [ 'read' ], 57 | }, { 58 | username: 'storeManagerA', 59 | abilities: [ 'create', 'read', 'update' ], 60 | }, { 61 | username: 'storeAdminA', 62 | abilities: [ 'create', 'read', 'update', 'delete' ], 63 | }, 64 | ] 65 | 66 | users.forEach(user => { 67 | describe(`${user.username} (User with ${user.abilities.join(', ')} permissions):`, function() { 68 | // related group content 69 | describe('group model', function() { 70 | if (_includes(user.abilities, 'read')) { 71 | it('should get a teams store', function() { 72 | return logInAs(user.username) 73 | .then(res => json('get', `/api/stores/A?access_token=${res.body.id}`) 74 | .expect(200)) 75 | .then(res => { 76 | expect(res.body).to.be.an('object') 77 | expect(res.body).to.have.property('name', 'Store A') 78 | }) 79 | }) 80 | } 81 | it('should not get another teams store', function() { 82 | return logInAs(user.username) 83 | .then(res => json('get', `/api/stores/B?access_token=${res.body.id}`) 84 | .expect(401)) 85 | }) 86 | }) 87 | 88 | // related group content 89 | describe('related group content', function() { 90 | if (_includes(user.abilities, 'read')) { 91 | it('should fetch an invoices related transactions from the same team', function() { 92 | return logInAs(user.username) 93 | .then(res => json('get', `/api/invoices/1/transactions?access_token=${res.body.id}`) 94 | .expect(200)) 95 | .then(res => { 96 | expect(res.body).to.be.an('array') 97 | expect(res.body).to.have.length(2) 98 | expect(res.body[0]).to.have.property('id', 1) 99 | expect(res.body[1]).to.have.property('id', 2) 100 | }) 101 | }) 102 | } 103 | it('should not fetch an invoice via a relationship from another teams transaction', function() { 104 | return logInAs(user.username) 105 | .then(res => json('get', `/api/transactions/3/invoice?access_token=${res.body.id}`) 106 | .expect(401)) 107 | }) 108 | }) 109 | // end related group content 110 | // exists 111 | describe('exists', function() { 112 | if (_includes(user.abilities, 'read')) { 113 | it('should check if a teams invoice exists by group id', function() { 114 | return logInAs(user.username) 115 | .then(res => json('get', `/api/invoices/1/exists?&access_token=${res.body.id}`) 116 | .expect(200)) 117 | .then(res => { 118 | expect(res.body).to.be.an('object') 119 | expect(res.body).to.have.property('exists', true) 120 | }) 121 | }) 122 | } 123 | else { 124 | it('should not check if a teams invoice exists by group id', function() { 125 | return logInAs(user.username) 126 | .then(res => json('get', `/api/invoices/1/exists?access_token=${res.body.id}`) 127 | .expect(401)) 128 | }) 129 | } 130 | it('should not check if another teams invoice exists by group id', function() { 131 | return logInAs(user.username) 132 | .then(res => json('get', `/api/invoices/2/exists?access_token=${res.body.id}`) 133 | .expect(401)) 134 | }) 135 | it('should return false when checking for existance of a invoice that doesnt exist', function() { 136 | return logInAs(user.username) 137 | .then(res => json('get', `/api/invoices/unknown-id/exists?access_token=${res.body.id}`) 138 | .expect(200)) 139 | .then(res => { 140 | expect(res.body).to.be.an('object') 141 | expect(res.body).to.have.property('exists', false) 142 | }) 143 | }) 144 | }) 145 | // end exists 146 | 147 | // count 148 | describe('count', function() { 149 | if (_includes(user.abilities, 'read')) { 150 | it('should count a teams invoices by group id', function() { 151 | return logInAs(user.username) 152 | .then(res => json('get', `/api/invoices/count?where[storeId]=A&access_token=${res.body.id}`) 153 | .expect(200)) 154 | .then(res => { 155 | expect(res.body).to.be.an('object') 156 | expect(res.body).to.have.property('count', 2) 157 | }) 158 | }) 159 | } 160 | else { 161 | it('should not find a teams invoices by group id', function() { 162 | return logInAs(user.username) 163 | .then(res => json('get', `/api/invoices/count?where[storeId]=A&access_token=${res.body.id}`) 164 | .expect(200)) 165 | .then(res => { 166 | expect(res.body).to.be.an('object') 167 | expect(res.body).to.have.property('count', 0) 168 | }) 169 | }) 170 | } 171 | it('should not count another teams invoices by group id', function() { 172 | return logInAs(user.username) 173 | .then(res => json('get', `/api/invoices/count?where[storeId]=B&access_token=${res.body.id}`) 174 | .expect(200)) 175 | .then(res => { 176 | expect(res.body).to.be.an('object') 177 | expect(res.body).to.have.property('count', 0) 178 | }) 179 | }) 180 | 181 | if (_includes(user.abilities, 'read')) { 182 | it('should count a teams invoices by invoiceNumber', function() { 183 | return logInAs(user.username) 184 | .then(res => json('get', `/api/invoices/count?where[invoiceNumber]=1&access_token=${res.body.id}`) 185 | .expect(200)) 186 | .then(res => { 187 | expect(res.body).to.be.an('object') 188 | expect(res.body).to.have.property('count', 1) 189 | }) 190 | }) 191 | } 192 | else { 193 | it('should not count a teams invoices by invoiceNumber', function() { 194 | return logInAs(user.username) 195 | .then(res => json('get', `/api/invoices/count?where[invoiceNumber]=1&access_token=${res.body.id}`) 196 | .expect(200)) 197 | .then(res => { 198 | expect(res.body).to.be.an('object') 199 | expect(res.body).to.have.property('count', 0) 200 | }) 201 | }) 202 | } 203 | it('should not count another teams invoices by invoiceNumber', function() { 204 | return logInAs(user.username) 205 | .then(res => json('get', `/api/invoices/count?where[invoiceNumber]=2&access_token=${res.body.id}`) 206 | .expect(200)) 207 | .then(res => { 208 | expect(res.body).to.be.an('object') 209 | expect(res.body).to.have.property('count', 0) 210 | }) 211 | }) 212 | 213 | const filter = JSON.stringify({ 214 | and: [ { 215 | status: 'active', 216 | }, { 217 | storeId: { 218 | inq: [ 'A', 'B' ], 219 | }, 220 | } ], 221 | }) 222 | 223 | if (_includes(user.abilities, 'read')) { 224 | it('should limit count results to a teams invoices with a complex filter', function() { 225 | return logInAs(user.username) 226 | .then(res => json('get', `/api/invoices/count?where=${filter}&access_token=${res.body.id}`) 227 | .expect(200)) 228 | .then(res => { 229 | expect(res.body).to.be.an('object') 230 | expect(res.body).to.have.property('count', 1) 231 | }) 232 | }) 233 | } 234 | else { 235 | it('should limit count results to a teams invoices with a complex filter', function() { 236 | return logInAs(user.username) 237 | .then(res => json('get', `/api/invoices/count?where=${filter}&access_token=${res.body.id}`) 238 | .expect(200)) 239 | .then(res => { 240 | expect(res.body).to.be.an('object') 241 | expect(res.body).to.have.property('count', 0) 242 | }) 243 | }) 244 | } 245 | }) 246 | // end count 247 | 248 | // find 249 | describe('find', function() { 250 | if (_includes(user.abilities, 'read')) { 251 | it('should find a teams invoices', function() { 252 | return logInAs(user.username) 253 | .then(res => json('get', `/api/invoices?access_token=${res.body.id}`) 254 | .expect(200)) 255 | .then(res => { 256 | expect(res.body).to.be.an('array') 257 | expect(res.body).to.have.length(2) 258 | expect(res.body[0]).to.have.property('invoiceNumber', 1) 259 | expect(res.body[1]).to.have.property('invoiceNumber', 3) 260 | }) 261 | }) 262 | it('should find a teams invoices by group id', function() { 263 | return logInAs(user.username) 264 | .then(res => json('get', `/api/invoices?filter[where][storeId]=A&access_token=${res.body.id}`) 265 | .expect(200)) 266 | .then(res => { 267 | expect(res.body).to.be.an('array') 268 | expect(res.body).to.have.length(2) 269 | expect(res.body[0]).to.have.property('invoiceNumber', 1) 270 | expect(res.body[1]).to.have.property('invoiceNumber', 3) 271 | }) 272 | }) 273 | } 274 | else { 275 | it('should not find a teams invoices', function() { 276 | return logInAs(user.username) 277 | .then(res => json('get', `/api/invoices?access_token=${res.body.id}`) 278 | .expect(200)) 279 | .then(res => { 280 | expect(res.body).to.be.an('array') 281 | expect(res.body).to.have.length(0) 282 | }) 283 | }) 284 | it('should not find a teams invoices by group id', function() { 285 | return logInAs(user.username) 286 | .then(res => json('get', `/api/invoices?filter[where][storeId]=A&access_token=${res.body.id}`) 287 | .expect(200)) 288 | .then(res => { 289 | expect(res.body).to.be.an('array') 290 | expect(res.body).to.have.length(0) 291 | }) 292 | }) 293 | } 294 | it('should not find another teams invoices by group id', function() { 295 | return logInAs(user.username) 296 | .then(res => json('get', `/api/invoices?filter[where][storeId]=B&access_token=${res.body.id}`) 297 | .expect(200)) 298 | .then(res => { 299 | expect(res.body).to.be.an('array') 300 | expect(res.body).to.have.length(0) 301 | }) 302 | }) 303 | 304 | if (_includes(user.abilities, 'read')) { 305 | it('should find a teams invoices by invoiceNumber', function() { 306 | return logInAs(user.username) 307 | .then(res => json('get', `/api/invoices?filter[where][invoiceNumber]=1&access_token=${res.body.id}`) 308 | .expect(200)) 309 | .then(res => { 310 | expect(res.body).to.be.an('array') 311 | expect(res.body).to.have.length(1) 312 | expect(res.body[0]).to.have.property('invoiceNumber', 1) 313 | }) 314 | }) 315 | } 316 | else { 317 | it('should not find a teams invoices by invoiceNumber', function() { 318 | return logInAs(user.username) 319 | .then(res => json('get', `/api/invoices?filter[where][invoiceNumber]=1&access_token=${res.body.id}`) 320 | .expect(200)) 321 | .then(res => { 322 | expect(res.body).to.be.an('array') 323 | expect(res.body).to.have.length(0) 324 | }) 325 | }) 326 | } 327 | it('should not find another teams invoices by invoiceNumber', function() { 328 | return logInAs(user.username) 329 | .then(res => json('get', `/api/invoices?filter[where][invoiceNumber]=2&access_token=${res.body.id}`) 330 | .expect(200)) 331 | .then(res => { 332 | expect(res.body).to.be.an('array') 333 | expect(res.body).to.have.length(0) 334 | }) 335 | }) 336 | 337 | const filter = JSON.stringify({ 338 | where: { 339 | and: [ { 340 | status: 'active', 341 | }, { 342 | storeId: { 343 | inq: [ 'A', 'B' ], 344 | }, 345 | } ], 346 | }, 347 | }) 348 | 349 | if (_includes(user.abilities, 'read')) { 350 | it('should limit find results to a teams invoices with a complex filter', function() { 351 | return logInAs(user.username) 352 | .then(res => json('get', `/api/invoices?filter=${filter}&access_token=${res.body.id}`) 353 | .expect(200)) 354 | .then(res => { 355 | expect(res.body).to.be.an('array') 356 | expect(res.body).to.have.length(1) 357 | expect(res.body[0]).to.have.property('invoiceNumber', 1) 358 | }) 359 | }) 360 | } 361 | else { 362 | it('should limit find results to a teams invoices with a complex filter', function() { 363 | return logInAs(user.username) 364 | .then(res => json('get', `/api/invoices?filter=${filter}&access_token=${res.body.id}`) 365 | .expect(200)) 366 | .then(res => { 367 | expect(res.body).to.be.an('array') 368 | expect(res.body).to.have.length(0) 369 | }) 370 | }) 371 | } 372 | }) 373 | // end find 374 | 375 | // findById 376 | describe('findById', function() { 377 | if (_includes(user.abilities, 'read')) { 378 | it('should get a teams invoice', function() { 379 | return logInAs(user.username) 380 | .then(res => json('get', `/api/invoices/1?access_token=${res.body.id}`) 381 | .expect(200)) 382 | .then(res => { 383 | expect(res.body).to.be.an('object') 384 | expect(res.body).to.have.property('invoiceNumber', 1) 385 | }) 386 | }) 387 | } 388 | else { 389 | it('should not get a teams invoice', function() { 390 | return logInAs(user.username) 391 | .then(res => json('get', `/api/invoices/1?access_token=${res.body.id}`) 392 | .expect(401)) 393 | }) 394 | } 395 | 396 | it('should not get another teams invoice', function() { 397 | return logInAs(user.username) 398 | .then(res => json('get', `/api/invoices/2?access_token=${res.body.id}`) 399 | .expect(401)) 400 | }) 401 | 402 | it('should return a 404 when getting a invoice that doesnt exist', function() { 403 | return logInAs(user.username) 404 | .then(res => json('get', `/api/invoices/unknown-id?access_token=${res.body.id}`) 405 | .expect(404)) 406 | }) 407 | }) 408 | // end findById 409 | 410 | // findOne 411 | describe('findOne', function() { 412 | if (_includes(user.abilities, 'read')) { 413 | it('should find a teams invoice by group id', function() { 414 | return logInAs(user.username) 415 | .then(res => json('get', `/api/invoices?filter[where][storeId]=A&access_token=${res.body.id}`) 416 | .expect(200)) 417 | .then(res => { 418 | expect(res.body).to.be.an('array') 419 | expect(res.body).to.have.length(2) 420 | expect(res.body[0]).to.have.property('invoiceNumber', 1) 421 | expect(res.body[1]).to.have.property('invoiceNumber', 3) 422 | }) 423 | }) 424 | } 425 | else { 426 | it('should not find a teams invoice by group id', function() { 427 | return logInAs(user.username) 428 | .then(res => json('get', `/api/invoices?filter[where][storeId]=A&access_token=${res.body.id}`) 429 | .expect(200)) 430 | .then(res => { 431 | expect(res.body).to.be.an('array') 432 | expect(res.body).to.have.length(0) 433 | }) 434 | }) 435 | } 436 | 437 | it('should not find another teams invoice by group id', function() { 438 | return logInAs(user.username) 439 | .then(res => json('get', `/api/invoices?filter[where][storeId]=B&access_token=${res.body.id}`) 440 | .expect(200)) 441 | .then(res => { 442 | expect(res.body).to.be.an('array') 443 | expect(res.body).to.have.length(0) 444 | }) 445 | }) 446 | 447 | if (_includes(user.abilities, 'read')) { 448 | it('should find a teams invoice by invoiceNumber', function() { 449 | return logInAs(user.username) 450 | .then(res => json('get', `/api/invoices?filter[where][invoiceNumber]=1&access_token=${res.body.id}`) 451 | .expect(200)) 452 | .then(res => { 453 | expect(res.body).to.be.an('array') 454 | expect(res.body).to.have.length(1) 455 | expect(res.body[0]).to.have.property('invoiceNumber', 1) 456 | }) 457 | }) 458 | } 459 | else { 460 | it('should not find a teams invoice by invoiceNumber', function() { 461 | return logInAs(user.username) 462 | .then(res => json('get', `/api/invoices?filter[where][invoiceNumber]=1&access_token=${res.body.id}`) 463 | .expect(200)) 464 | .then(res => { 465 | expect(res.body).to.be.an('array') 466 | expect(res.body).to.have.length(0) 467 | }) 468 | }) 469 | } 470 | 471 | it('should not find another teams invoice by invoiceNumber', function() { 472 | return logInAs(user.username) 473 | .then(res => json('get', `/api/invoices?filter[where][invoiceNumber]=2&access_token=${res.body.id}`) 474 | .expect(200)) 475 | .then(res => { 476 | expect(res.body).to.be.an('array') 477 | expect(res.body).to.have.length(0) 478 | }) 479 | }) 480 | }) 481 | // end findOne 482 | 483 | // create 484 | describe('create', function() { 485 | let invoiceId = null 486 | 487 | if (_includes(user.abilities, 'create')) { 488 | it('should create a teams invoice', function() { 489 | return logInAs(user.username) 490 | .then(res => json('post', `/api/invoices?access_token=${res.body.id}`) 491 | .send({ storeId: 'A', invoiceNumber: 100 }) 492 | .expect(200)) 493 | .then(res => { 494 | expect(res.body).to.be.an('object') 495 | expect(res.body).to.have.property('invoiceNumber', 100) 496 | invoiceId = res.body.id 497 | }) 498 | }) 499 | } 500 | else { 501 | it('should not create a teams invoice', function() { 502 | return logInAs(user.username) 503 | .then(res => json('post', `/api/invoices?access_token=${res.body.id}`) 504 | .send({ storeId: 'A', name: 'A invoice' }) 505 | .expect(401)) 506 | }) 507 | } 508 | 509 | it('should not create another teams invoice', function() { 510 | return logInAs(user.username) 511 | .then(res => json('post', `/api/invoices?access_token=${res.body.id}`) 512 | .send({ storeId: 'B', name: 'A invoice' }) 513 | .expect(401)) 514 | }) 515 | 516 | after(function() { 517 | if (invoiceId) { 518 | return app.models.Invoice.destroyById(invoiceId) 519 | } 520 | return null 521 | }) 522 | }) 523 | // end create 524 | 525 | // upsert 526 | describe('upsert', function() { 527 | if (_includes(user.abilities, 'update')) { 528 | it('should update a teams invoice', function() { 529 | return logInAs(user.username) 530 | .then(res => json('put', `/api/invoices?access_token=${res.body.id}`) 531 | .send({ 532 | id: 1, 533 | storeId: 'A', 534 | invoiceNumber: 1, 535 | someprop: 'someval', 536 | }) 537 | .expect(200)) 538 | .then(res => { 539 | expect(res.body).to.be.an('object') 540 | expect(res.body).to.have.property('someprop', 'someval') 541 | }) 542 | }) 543 | it('should not reassign a invoice to another team', function() { 544 | return logInAs(user.username) 545 | .then(res => json('put', `/api/invoices?access_token=${res.body.id}`) 546 | .send({ 547 | id: 1, 548 | storeId: 'B', 549 | invoiceNumber: 1, 550 | someprop: 'someval', 551 | }) 552 | .expect(401)) 553 | }) 554 | } 555 | else { 556 | it('should not update a teams invoice', function() { 557 | return logInAs(user.username) 558 | .then(res => json('put', `/api/invoices?access_token=${res.body.id}`) 559 | .send({ 560 | id: 1, 561 | storeId: 'A', 562 | invoiceNumber: 1, 563 | someprop: 'someval', 564 | }) 565 | .expect(401)) 566 | }) 567 | } 568 | it('should not update another teams invoice', function() { 569 | return logInAs(user.username) 570 | .then(res => json('put', `/api/invoices?access_token=${res.body.id}`) 571 | .send({ 572 | id: 2, 573 | storeId: 'B', 574 | invoiceNumber: 1, 575 | someprop: 'someval', 576 | }) 577 | .expect(401)) 578 | }) 579 | it('should not reassign another teams invoice to our team', function() { 580 | return logInAs(user.username) 581 | .then(res => json('put', `/api/invoices?access_token=${res.body.id}`) 582 | .send({ 583 | id: 2, 584 | storeId: 'A', 585 | invoiceNumber: 2, 586 | someprop: 'someval', 587 | }) 588 | .expect(401)) 589 | }) 590 | }) 591 | // end upsert 592 | 593 | // updateAttributes 594 | describe('updateAttributes', function() { 595 | if (_includes(user.abilities, 'update')) { 596 | it('should update a teams invoice attributes', function() { 597 | return logInAs(user.username) 598 | .then(res => json('put', `/api/invoices/1?access_token=${res.body.id}`) 599 | .send({ someprop: 'someval' }) 600 | .expect(200)) 601 | .then(res => { 602 | expect(res.body).to.be.an('object') 603 | expect(res.body).to.have.property('someprop', 'someval') 604 | }) 605 | }) 606 | it('should not reassign a invoice to another team', function() { 607 | return logInAs(user.username) 608 | .then(res => json('put', `/api/invoices/1?access_token=${res.body.id}`) 609 | .send({ storeId: 'B' }) 610 | .expect(401)) 611 | }) 612 | } 613 | else { 614 | it('should update a teams invoice attributes', function() { 615 | return logInAs(user.username) 616 | .then(res => json('put', `/api/invoices/1?access_token=${res.body.id}`) 617 | .send({ someprop: 'someval' }) 618 | .expect(401)) 619 | }) 620 | } 621 | 622 | it('should not update another teams invoice attributes', function() { 623 | return logInAs(user.username) 624 | .then(res => json('put', `/api/invoices/2?access_token=${res.body.id}`) 625 | .send({ someprop: 'someval' }) 626 | .expect(401)) 627 | }) 628 | it('should not reassign another teams invoice to our team', function() { 629 | return logInAs(user.username) 630 | .then(res => json('put', `/api/invoices/2?access_token=${res.body.id}`) 631 | .send({ storeId: 'A' }) 632 | .expect(401)) 633 | }) 634 | }) 635 | // end updateAttributes 636 | 637 | // destroyById 638 | describe('destroyById', function() { 639 | if (_includes(user.abilities, 'delete')) { 640 | it('should delete a teams invoice', function() { 641 | return logInAs(user.username) 642 | .then(res => json('delete', `/api/invoices/1?access_token=${res.body.id}`) 643 | .expect(200)) 644 | .then(res => { 645 | expect(res.body).to.be.an('object') 646 | expect(res.body).to.have.property('count', 1) 647 | }) 648 | }) 649 | } 650 | else { 651 | it('should not delete a teams invoice', function() { 652 | return logInAs(user.username) 653 | .then(res => json('delete', `/api/invoices/1?access_token=${res.body.id}`) 654 | .expect(401)) 655 | }) 656 | } 657 | it('should not delete another teams invoice', function() { 658 | return logInAs(user.username) 659 | .then(res => json('delete', `/api/invoices/2?access_token=${res.body.id}`) 660 | .expect(401)) 661 | }) 662 | }) 663 | // end destroyById, 664 | }) 665 | }) 666 | }) 667 | -------------------------------------------------------------------------------- /test/utils-test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const chai = require('chai') 5 | const { expect } = chai 6 | 7 | chai.use(require('dirty-chai')) 8 | chai.use(require('sinon-chai')) 9 | 10 | require('mocha-sinon') 11 | 12 | const SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-app') 13 | const app = require(path.join(SIMPLE_APP, 'server/server.js')) 14 | 15 | describe('Utils', function() { 16 | describe('isGroupModel', function() { 17 | it('should return true for a group model', function() { 18 | const res = app.accessUtils.isGroupModel('Store') 19 | 20 | expect(res).to.be.true() 21 | }) 22 | it('should return false for a model that is not a group model', function() { 23 | const res = app.accessUtils.isGroupModel('user') 24 | 25 | expect(res).to.be.false() 26 | }) 27 | }) 28 | 29 | describe('getGroupContentModels', function() { 30 | it('should return a list of group content models', function() { 31 | const groupContentModels = app.accessUtils.getGroupContentModels() 32 | 33 | expect(groupContentModels).to.be.an('array') 34 | expect(groupContentModels).to.deep.equal([ 'Invoice', 'Transaction' ]) 35 | }) 36 | }) 37 | 38 | describe('buildFilter', function() { 39 | it('should return a where condition that includes all groups for a user (no groups)', function() { 40 | return app.accessUtils.buildFilter('generalUser') 41 | .then(filter => { 42 | expect(filter).to.deep.equal({ 43 | storeId: { 44 | inq: [], 45 | }, 46 | }) 47 | }) 48 | }) 49 | it('should return a where condition that includes all groups for a user (1 group)', function() { 50 | return app.accessUtils.buildFilter('storeAdminA') 51 | .then(filter => { 52 | expect(filter).to.deep.equal({ 53 | storeId: { 54 | inq: [ 'A' ], 55 | }, 56 | }) 57 | }) 58 | }) 59 | }) 60 | 61 | describe('getUserGroups', function() { 62 | it('should return a list of groups for a user', function() { 63 | return app.accessUtils.getUserGroups('generalUser') 64 | .then(groups => { 65 | expect(groups).to.be.an('array') 66 | expect(groups).to.have.length(0) 67 | return app.accessUtils.getUserGroups('storeAdminA') 68 | }) 69 | .then(groups => { 70 | expect(groups).to.be.an('array') 71 | expect(groups).to.have.length(1) 72 | expect(groups[0]).to.have.property('storeId', 'A') 73 | expect(groups[0]).to.have.property('role', 'admin') 74 | return app.accessUtils.getUserGroups('storeManagerA') 75 | }) 76 | .then(groups => { 77 | expect(groups).to.be.an('array') 78 | expect(groups).to.have.length(1) 79 | expect(groups[0]).to.have.property('storeId', 'A') 80 | expect(groups[0]).to.have.property('role', 'manager') 81 | }) 82 | }) 83 | }) 84 | }) 85 | --------------------------------------------------------------------------------