Skip to content

Instantly share code, notes, and snippets.

@pulkitsinghal
Last active February 13, 2018 18:43
Show Gist options
  • Save pulkitsinghal/1152cad3b66a640a0d52e007ee8aa373 to your computer and use it in GitHub Desktop.
Save pulkitsinghal/1152cad3b66a640a0d52e007ee8aa373 to your computer and use it in GitHub Desktop.
Raw source for the steps in add-multi-tenancy.md
module.exports = function(app){
var RoleMapping = app.models.RoleMapping;
var UserModel = app.models.UserModel;
var Role = app.models.Role;
RoleMapping.belongsTo(UserModel);
UserModel.hasMany(RoleMapping, {foreignKey: 'principalId'});
UserModel.hasMany(Role, {as:'roles', through: RoleMapping, foreignKey: 'principalId'});
Role.hasMany(UserModel, {through: RoleMapping, foreignKey: 'roleId'});
};
'use strict';
module.exports = function (app, cb) {
var Role = app.models.Role;
var Promise = require('bluebird'); // jshint ignore:line
Promise.resolve()
.then(function () {
return Role.findOrCreate(
{where: {name: 'orgAdmin'}}, // either find
{ // or create
name: 'orgAdmin',
description: 'Org Admins' + '\n' +
'- should be able to read-update-delete their organization' + '\n' +
'- should be able to create-read-update-delete any users within their organization' + '\n' +
'- should be able to add new users to their organization'
}
);
})
.spread(function (created, found) {
return Role.findOrCreate(
{where: {name: 'orgUser'}}, // either find
{ // or create
name: 'orgUser',
description: 'Org Users' + '\n' +
'- should be able to create-read-update-delete any models within their organization, other than OrgModel'
}
);
})
.spread(function (created, found) {
return cb();
})
.catch(function (error) {
return cb(error);
});
};
'use strict';
module.exports = function (app) {
var Role = app.models.Role;
var roles = [
'orgAdmin',
'orgUser'
];
var _ = require('underscore');
_.each(roles, function (eachRole) {
var log = require('debug')('loopback:security:role-resolver:'+eachRole);
Role.registerResolver(eachRole, function (role, context, cb) {
function reject(reason, err) {
log('DENY'
+ '\n' + '\t' + context.remotingContext.req.method + ' ' + context.remotingContext.req.originalUrl
+ '\n' + '\t' + 'Reason: ' +reason);
if (err) {
return cb(err);
}
cb(null, false);
}
if (context.modelName !== 'OrgModel') {
return reject('target model is not OrgModel'); // return error if target model is not OrgModel
}
var currentOrg = context.modelId;
if (!currentOrg) {
return reject('an exact OrgModel isn\'t specified'); // return error if an exact OrgModel isn't specified
}
var currentUserId = context.accessToken.userId;
if (!currentUserId) {
return reject('do not allow unauthenticated users to proceed'); // do not allow unauthenticated users to proceed
}
else {
app.models.UserModel.findById(currentUserId, {
include: {
relation: 'roles',
scope: {
fields: ['name'] // only include the role name and id
}
}
})
.then(function (userModelInstance) {
if(!userModelInstance.orgModelId){
return reject('user does not belong to any organization'); // reject users who do not belong to any organization
}
if (!_.isEqual(userModelInstance.orgModelId.toString(), currentOrg.toString())) {
return reject('user does not belong to the given organization'); // reject users who do not belong to the given organization
}
var isValidUser = _.findWhere(userModelInstance.roles(), {name: eachRole});
if(!isValidUser) {
return reject('user does not have this role '+eachRole+' assigned'); // reject users who haven't been assigned the given role
}
else {
log('ALLOW an authenticated user who belongs to this organization and has this role assigned');
return cb(null, true);
}
})
.catch(function (error) {
cb(error);
});
}
});
});
};
module.exports = function (OrgModel) {
// Hiding methods and REST endpoints
// https://loopback.io/doc/en/lb3/Exposing-models-over-REST.html#hiding-methods-and-rest-endpoints
// Clients should not be able to create organizations
OrgModel.disableRemoteMethodByName("create"); // disables POST /OrgModels
OrgModel.disableRemoteMethodByName("upsert"); // disables PATCH /OrgModels
OrgModel.disableRemoteMethodByName("replaceOrCreate"); // disables PUT /OrgModels
OrgModel.disableRemoteMethodByName("replaceById"); // disables PUT /OrgModels/{id}
OrgModel.disableRemoteMethodByName("upsertWithWhere"); // disables POST /OrgModels/upsertWithWhere
// Clients should not be able to discover organizations
OrgModel.disableRemoteMethodByName("count"); // disables HEAD /OrgModels/count
OrgModel.disableRemoteMethodByName("find"); // disables GET /OrgModels
OrgModel.disableRemoteMethodByName("findOne"); // disables GET /OrgModels/findOne
OrgModel.disableRemoteMethodByName("findById"); // disables GET /OrgModels/{id}
OrgModel.disableRemoteMethodByName("exists"); // disables HEAD /OrgModels/{id}
// Certain organization operations should take place on server-side only
// and aren't meant to be exposed to the client-side
//OrgModel.disableRemoteMethodByName("update"); // disables POST /OrgModels/update
//OrgModel.disableRemoteMethodByName("prototype.updateAttributes"); // disables PATCH /OrgModels/{id}
//OrgModel.disableRemoteMethodByName("deleteById"); // disables DELETE /OrgModels/{id}
// An organization does not need login related functionality
OrgModel.disableRemoteMethodByName('login');
OrgModel.disableRemoteMethodByName('logout');
OrgModel.disableRemoteMethodByName('confirm');
OrgModel.disableRemoteMethodByName("resetPassword"); // disables POST /OrgModels/reset
OrgModel.disableRemoteMethodByName("setPassword"); // disables POST /OrgModels/reset-password
OrgModel.disableRemoteMethodByName("prototype.verify"); // disable POST /OrgModels/{id}/verify
OrgModel.disableRemoteMethodByName("changePassword"); // disable POST /OrgModels/change-password
// Don't expose what you don't need to
OrgModel.disableRemoteMethodByName("createChangeStream"); // disable GET and POST /OrgModels/change-stream
// An organization does not need accessToken related functionality
OrgModel.disableRemoteMethodByName('prototype.__count__accessTokens');
OrgModel.disableRemoteMethodByName('prototype.__create__accessTokens');
OrgModel.disableRemoteMethodByName('prototype.__delete__accessTokens');
OrgModel.disableRemoteMethodByName('prototype.__destroyById__accessTokens');
OrgModel.disableRemoteMethodByName('prototype.__findById__accessTokens');
OrgModel.disableRemoteMethodByName('prototype.__get__accessTokens');
OrgModel.disableRemoteMethodByName('prototype.__updateById__accessTokens');
OrgModel.on('dataSourceAttached', function () {
delete OrgModel.validations.password; // An organization does not require a password
});
OrgModel.on('attached', function () {
var app = OrgModel.app;
/**
* Any OrgModel related validations should be placed here
*/
OrgModel.validatesUniquenessOf('email');
});
};
{
"name": "OrgModel",
"base": "User",
"idInjection": true,
"options": {
"validateUpsert": true
},
"properties": {
"name": {
"type": "string",
"required": true,
"default": "none"
},
"email": {
"type": "string"
}
},
"validations": [],
"relations": {
},
"acls": [
],
"methods": {}
}
{
"name": "OrgModel",
"base": "User",
"idInjection": true,
"options": {
"validateUpsert": true
},
"properties": {
"name": {
"type": "string",
"required": true,
"default": "none"
},
"email": {
"type": "string"
}
},
"validations": [],
"relations": {
"users": {
"type": "hasMany",
"model": "UserModel",
"foreignKey": "orgModelId"
}
},
"acls": [
],
"methods": {}
}
{
"name": "OrgModel",
"base": "User",
"idInjection": true,
"options": {
"validateUpsert": true
},
"properties": {
"name": {
"type": "string",
"required": true,
"default": "none"
},
"email": {
"type": "string"
}
},
"validations": [],
"relations": {
"users": {
"type": "hasMany",
"model": "UserModel",
"foreignKey": "orgModelId"
}
},
"acls": [
{
"description": "DENY any and all requests which we do not explicitly ALLOW",
"accessType": "*",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "DENY"
},
{
"description": "Admins can CRUD users within their own org",
"accessType": "EXECUTE",
"principalType": "ROLE",
"principalId": "orgAdmin",
"permission": "ALLOW",
"property": [
"__count__users",
"__create__users",
"__delete__users",
"__destroyById__users",
"__findById__users",
"__get__users",
"__updateById__users"
]
}
],
"methods": {}
}
{
"name": "OrgModel",
"base": "User",
"idInjection": true,
"options": {
"validateUpsert": true
},
"mixins": {
},
"properties": {
"name": {
"type": "string",
"required": true,
"default": "none"
},
"email": {
"type": "string"
}
},
"validations": [],
"relations": {
"users": {
"type": "hasMany",
"model": "UserModel",
"foreignKey": "orgModelId"
},
"stuffModels": {
"type": "hasMany",
"model": "StuffModel",
"foreignKey": "orgModelId"
}
},
"acls": [
{
"description": "DENY any and all requests which we do not explicitly ALLOW",
"accessType": "*",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "DENY"
},
{
"description": "Admins can CRUD users within their own org",
"accessType": "EXECUTE",
"principalType": "ROLE",
"principalId": "orgAdmin",
"permission": "ALLOW",
"property": [
"__count__users",
"__create__users",
"__delete__users",
"__destroyById__users",
"__findById__users",
"__get__users",
"__updateById__users"
]
},
{
"description": "Users can CRUD stuff within their own org",
"accessType": "EXECUTE",
"principalType": "ROLE",
"principalId": "orgUser",
"permission": "ALLOW",
"property": [
"__count__stuffModels",
"__create__stuffModels",
"__delete__stuffModels",
"__destroyById__stuffModels",
"__findById__stuffModels",
"__get__stuffModels",
"__updateById__stuffModels"
]
}
],
"methods": {}
}
module.exports = function(StuffModel) {
};
{
"name": "StuffModel",
"base": "PersistedModel",
"idInjection": true,
"options": {
"validateUpsert": true
},
"mixins": {
},
"properties": {
"name": {
"type": "string",
"required": true
}
},
"validations": [],
"relations": {
"org": {
"type": "belongsTo",
"model": "OrgModel",
"foreignKey": "orgModelId",
"scope": {
"fields": {
"id": true,
"name": true,
"uniqueName": true
}
}
}
},
"acls": [
{
"description": "DENY any and all requests which we do not explicitly ALLOW",
"accessType": "*",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "DENY"
}
],
"methods": {}
}
var path = require('path');
var fileName = path.basename(__filename, '.js'); // gives the filename without the .js extension
var log = require('debug')('common:models:' + fileName);
var Promise = require('bluebird');
var Joi = Promise.promisifyAll(require('joi'));
var validate = Promise.promisify(require('joi').validate);
var _ = require('underscore');
module.exports = function (UserModel) {
// Hiding methods and REST endpoints
// https://loopback.io/doc/en/lb3/Exposing-models-over-REST.html#hiding-methods-and-rest-endpoints
// Clients should not be able to create users (BUT signups are a different story)
UserModel.disableRemoteMethodByName("create"); // disables POST /UserModels
UserModel.disableRemoteMethodByName("upsert"); // disables PATCH /UserModels
UserModel.disableRemoteMethodByName("replaceOrCreate"); // disables PUT /UserModels
UserModel.disableRemoteMethodByName("replaceById"); // disables PUT /UserModels/{id}
UserModel.disableRemoteMethodByName("upsertWithWhere"); // disables POST /UserModels/upsertWithWhere
UserModel.on('attached', function () {
var app = UserModel.app;
var Role = app.models.Role;
var RoleMapping = app.models.RoleMapping;
UserModel.remoteMethod('signup', {
accepts: [
{ arg: 'data', type: 'object', required: true, http: { source: 'body' } },
{ arg: 'options', type: 'object', http: 'optionsFromRequest' }
],
http: { path: '/signup', verb: 'post' },
returns: { type: 'string', root: true }
});
UserModel.signup = function (data, options, cb) {
log('initiating sign-up', data);
var OrgModel = UserModel.app.models.OrgModel;
var validObjectSchema = Joi.object().keys({
'orgName': Joi.string().required(),
'email': Joi.string().email().required(),
'password': Joi.string().min(8).max(15).required()
});
var orgData = {
name: data.orgName,
email: data.email
};
delete data.username;
var orgCreated = {};
var userCreated = {};
validate(data, validObjectSchema)
.then(function () {
return OrgModel.create(orgData);
})
.then(function (orgInstance) {
delete data.orgName;
data.username = data.email;
orgCreated = orgInstance; //creating object reference instead of copying, so that can be accessed in catch block
return orgInstance.users.create(data, options);
})
.then(function (userInstance) {
userCreated = userInstance;
cb(null, userCreated);
})
.catch(function (error) {
log('Error creating organization, rolling back ...', JSON.stringify(error, null, 2));
if (!_.isEmpty(orgCreated)) {
OrgModel.deleteById(orgCreated.id)
.then(function () {
if (!_.isEmpty(userCreated)) {
return UserModel.deleteById(userCreated.id);
}
else {
return Promise.resolve();
}
})
.then(function () {
if (error && error.details && error.details.codes && error.details.codes.email && error.details.codes.email[0] === 'uniqueness') {
cb({ 'property': 'email', 'message': 'This email address already exists.' });
}
else {
cb('Internal Server Error. Please try again.');
}
})
.catch(function (anotherError) {
log('signup error', anotherError);
cb('Internal Server Error. Please try again.');
});
}
else {
cb(error);
}
});
};
});
};
{
"name": "UserModel",
"base": "User",
"idInjection": true,
"options": {
"validateUpsert": true
},
"mixins": {
},
"properties": {
},
"validations": [],
"relations": {
"org": {
"type": "belongsTo",
"model": "OrgModel",
"foreignKey": "orgModelId"
}
},
"acls": [
{
"description": "DENY any and all requests which we do not explicitly ALLOW",
"accessType": "*",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "DENY"
},
{
"description": "Anyone can signup",
"accessType": "EXECUTE",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW",
"property": "signup"
}
],
"methods": {}
}
var path = require('path');
var fileName = path.basename(__filename, '.js'); // gives the filename without the .js extension
var log = require('debug')('common:models:' + fileName);
var Promise = require('bluebird');
var Joi = Promise.promisifyAll(require('joi'));
var validate = Promise.promisify(require('joi').validate);
var _ = require('underscore');
module.exports = function (UserModel) {
// Hiding methods and REST endpoints
// https://loopback.io/doc/en/lb3/Exposing-models-over-REST.html#hiding-methods-and-rest-endpoints
// Clients should not be able to create users (BUT signups are a different story)
UserModel.disableRemoteMethodByName("create"); // disables POST /UserModels
UserModel.disableRemoteMethodByName("upsert"); // disables PATCH /UserModels
UserModel.disableRemoteMethodByName("replaceOrCreate"); // disables PUT /UserModels
UserModel.disableRemoteMethodByName("replaceById"); // disables PUT /UserModels/{id}
UserModel.disableRemoteMethodByName("upsertWithWhere"); // disables POST /UserModels/upsertWithWhere
UserModel.on('attached', function () {
var app = UserModel.app;
var Role = app.models.Role;
var RoleMapping = app.models.RoleMapping;
/**
* Overrides the `create` method
* - must restrict access so its only called from server-side and not client-side
*/
var overriddenCreate = UserModel.create;
UserModel.create = function(data, options, callback) {
log('OVERRIDING UserModel create method');
console.assert(data.orgModelId); // sanity check
// if its a direct api call via `POST /OrgModels/:id/users` then loopback will add this value
// if its a server side api by developers then they must add this value
// handle both callbacks and promise based invocations
var loopbackUtils = require('loopback/lib/utils');
originalCallback = callback || loopbackUtils.createPromiseCallback();
// nest callbacks to attach desired functionality, post-create
callback = function(error, userInstance) {
if (error) {
originalCallback(error);
}
else {
// Is `selfEditingOrgUser` a better role name than `orgUser:self` ???
// if the code was not split across two "boot" files then
// there wouldn't be need to register a role resolver under the name `orgUser:self`
// because having all the code under one file registered with role-resolver named `orgUser`
// would let us handle permissions to OrgModel and UserModel in one place under one named role
var rolesToAssign = ['orgUser', 'orgUser:self'];
UserModel.assignRoles(rolesToAssign, userInstance, options)
.then(function(){
originalCallback(null, userInstance);
})
.catch(function(error){
originalCallback(error);
});
}
};
// call original `create` method
var self = this;
var argsForCreate = arguments;
overriddenCreate.apply(self, argsForCreate);
}; // END of UserModel.create
UserModel.remoteMethod('signup', {
accepts: [
{ arg: 'data', type: 'object', required: true, http: { source: 'body' } },
{ arg: 'options', type: 'object', http: 'optionsFromRequest' }
],
http: { path: '/signup', verb: 'post' },
returns: { type: 'string', root: true }
});
UserModel.signup = function (data, options, cb) {
log('initiating sign-up', data);
var OrgModel = UserModel.app.models.OrgModel;
var validObjectSchema = Joi.object().keys({
'orgName': Joi.string().required(),
'email': Joi.string().email().required(),
'password': Joi.string().min(8).max(15).required()
});
var orgData = {
name: data.orgName,
email: data.email
};
delete data.username;
var orgCreated = {};
var userCreated = {};
validate(data, validObjectSchema)
.then(function () {
return OrgModel.create(orgData);
})
.then(function (orgInstance) {
delete data.orgName;
data.username = data.email;
orgCreated = orgInstance; //creating object reference instead of copying, so that can be accessed in catch block
return orgInstance.users.create(data, options);
})
.then(function (userInstance) {
userCreated = userInstance;
var rolesToAssign = ['orgAdmin']; // for users who "self-signup", one additional role MUST be that of `orgAdmin`
return UserModel.assignRoles(rolesToAssign, userInstance, options);
})
.then(function () {
cb(null, userCreated);
})
.catch(function (error) {
log('Error creating organization, rolling back ...', JSON.stringify(error, null, 2));
if (!_.isEmpty(orgCreated)) {
OrgModel.deleteById(orgCreated.id)
.then(function () {
if (!_.isEmpty(userCreated)) {
return UserModel.deleteById(userCreated.id);
}
else {
return Promise.resolve();
}
})
.then(function () {
if (error && error.details && error.details.codes && error.details.codes.email && error.details.codes.email[0] === 'uniqueness') {
cb({ 'property': 'email', 'message': 'This email address already exists.' });
}
else {
cb('Internal Server Error. Please try again.');
}
})
.catch(function (anotherError) {
log('signup error', anotherError);
cb('Internal Server Error. Please try again.');
});
}
else {
cb(error);
}
});
};
UserModel.assignRoles = function (rolesToAssign, userInstance, options) {
var Role = UserModel.app.models.Role;
var RoleMapping = UserModel.app.models.RoleMapping;
var orConditions = [];
rolesToAssign.forEach(function (eachRole) {
orConditions.push({ name: eachRole });
});
return Role.find({
where: {
or: orConditions
}
})
.then(function (roles) {
return Promise.map(roles, function (eachRole) {
log('Assigning role ' + eachRole.name);
return RoleMapping.create({ roleId: eachRole.id, principalId: userInstance.id });
});
})
.then(function (result) {
log('Finished assigning roles to user');
return Promise.resolve(result);
})
.catch(function (error) {
log('Error assigning roles', error);
return Promise.reject(error);
});
};
});
};
{
"name": "UserModel",
"base": "User",
"idInjection": true,
"options": {
"validateUpsert": true
},
"mixins": {
"TimeStamp": {
"createdAt" : "created",
"updatedAt" : "modified",
"required" : true
},
"Context": {},
"ResetPassword": {}
},
"properties": {
},
"validations": [],
"relations": {
"org": {
"type": "belongsTo",
"model": "OrgModel",
"foreignKey": "orgModelId"
}
},
"acls": [
{
"description": "DENY any and all requests which we do not explicitly ALLOW",
"accessType": "*",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "DENY"
},
{
"description": "Anyone can signup",
"accessType": "EXECUTE",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW",
"property": "signup"
},
{
"description": "Users can access their own profile",
"accessType": "EXECUTE",
"principalType": "ROLE",
"principalId": "$owner",
"permission": "ALLOW",
"property": "profile"
}
],
"methods": {}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment