Skip to content

Instantly share code, notes, and snippets.

@spencermefford
Created July 28, 2015 02:51
Show Gist options
  • Save spencermefford/bc73812f216e0e254ad1 to your computer and use it in GitHub Desktop.
Save spencermefford/bc73812f216e0e254ad1 to your computer and use it in GitHub Desktop.
An alternative to extending Loopback's built in models. In our application, we wanted to create a custom role called "ecm-administrator" that would have the ability to create and manage users.
module.exports = function (app) {
var _ = require('lodash');
var User = app.models.User;
var Role = app.models.Role;
var RoleMapping = app.models.RoleMapping;
var ACL = app.models.ACL;
/*
* Configure ACL's
*/
ACL.create({
model: 'User',
property: '*',
accessType: '*',
principalType: 'ROLE',
principalId: 'ecm-administrator',
permission: 'ALLOW'
}, function (err, acl) { // Create the acl
if (err) console.error(err);
});
ACL.create({
model: 'Role',
property: '*',
accessType: '*',
principalType: 'ROLE',
principalId: 'ecm-administrator',
permission: 'ALLOW'
}, function (err, acl) { // Create the acl
if (err) console.error(err);
});
ACL.create({
model: 'RoleMapping',
property: '*',
accessType: '*',
principalType: 'ROLE',
principalId: 'ecm-administrator',
permission: 'ALLOW'
}, function (err, acl) { // Create the acl
if (err) console.error(err);
});
/*
* Add hooks
*/
RoleMapping.observe('before save', function filterProperties(ctx, next) {
/*
* Since there is no built in method to add users to roles in Loopback via REST API, we have leveraged
* the hasManyThrough relationship to handle this. Unfortunately, the RoleMapping model has an extra
* field called principalType that a typical join table would not have. We have to manually set this.
*/
if (_.isEmpty(ctx.instance.principalType)) { // If no principalType has been set...
ctx.instance.principalType = RoleMapping.USER; // Set it to USER since it's likely that the User REST API is creating this
}
if (!_.isEmpty(ctx.instance.userId)) {
ctx.instance.principalId = ctx.instance.userId;
ctx.instance.unsetAttribute('userId');
}
next();
});
/*
* Configure relationships
*/
RoleMapping.belongsTo(User);
RoleMapping.belongsTo(Role);
User.hasMany(Role, {through: RoleMapping, foreignKey: 'principalId'});
User.hasMany(RoleMapping, {foreignKey: 'principalId'});
Role.hasMany(User, {through: RoleMapping, foreignKey: 'roleId'});
/*
* Add additional attributes to models.
*/
Role.defineProperty('label', { type: 'string' }); // Add a role label that is user readable
User.defineProperty('firstName', { type: 'string' }); // Give the user a first name field
User.defineProperty('lastName', { type: 'string' }); // Give the user a last name field
/**
* Add a user to the given role.
* @param {string} userId
* @param {string} roleId
* @param {Function} cb
*/
User.addRole = function(userId, roleId, cb) {
var error;
User.findOne({ where: { id: userId } }, function(err, user) { // Find the user...
if (err) cb(err); // Error
if (!_.isEmpty(user)) {
Role.findOne({ where: { id: roleId } }, function(err, role) { // Find the role...
if (err) cb(err); // Error
if (!_.isEmpty(role)) {
RoleMapping.findOne({ where: { principalId: userId, roleId: roleId } }, function(err, roleMapping) { // Find the role mapping...
if (err) cb(err); // Error
if (_.isEmpty(roleMapping)) { // Only create if one doesn't exist to avoid duplicates
role.principals.create({
principalType: RoleMapping.USER,
principalId: user.id
}, function(err, principal) {
if (err) cb(err); // Error
cb(null, role); // Success, return role object
});
} else {
cb(null, role); // Success, return role object
}
});
} else {
error = new Error('Role.' + roleId + ' was not found.');
error.http_code = 404;
cb(error); // Error
}
});
} else {
error = new Error('User.' + userId + ' was not found.');
error.http_code = 404;
cb(error); // Error
}
});
};
User.remoteMethod(
'addRole',
{
accepts: [
{arg: 'userId', type: 'string'},
{arg: 'roleId', type: 'string'}
],
http: {
path: '/add-role',
verb: 'post'
},
returns: {type: 'object', root: true}
}
);
/**
* Remove a user from the given role.
* @param {string} userId
* @param {string} roleId
* @param {Function} cb
*/
User.removeRole = function(userId, roleId, cb) {
var error;
User.findOne({ where: { id: userId } }, function(err, user) { // Find the user...
if (err) cb(err); // Error
if (!_.isEmpty(user)) {
Role.findOne({ where: { id: roleId } }, function(err, role) { // Find the role...
if (err) cb(err); // Error
if (!_.isEmpty(role)) {
RoleMapping.findOne({ where: { principalId: userId, roleId: roleId } }, function(err, roleMapping) { // Find the role mapping...
if (err) cb(err); // Error
if (!_.isEmpty(roleMapping)) {
roleMapping.destroy(function(err) {
if (err) cb(err); // Error
cb(null, role); // Success, return role object
});
} else {
cb(null, role); // Success, return role object
}
});
} else {
error = new Error('Role.' + roleId + ' was not found.');
error.http_code = 404;
cb(error); // Error
}
});
} else {
error = new Error('User.' + userId + ' was not found.');
error.http_code = 404;
cb(error); // Error
}
});
};
User.remoteMethod(
'removeRole',
{
accepts: [
{arg: 'userId', type: 'string'},
{arg: 'roleId', type: 'string'}
],
http: {
path: '/remove-role',
verb: 'post'
}
}
);
};
@spencermefford
Copy link
Author

In our application, we wanted to create a custom role called "ecm-administrator" that would have the ability to create and manage users. I extended the ACL of the built-in models so a user with this role could manage any of those operations (create/edit user, add/delete roles, etc). We did not need to deny all because we left the default behavior in place, which allows a user to manage his/her own object. We just opened everything up for the "ecm-administrator" role.

I did this using a boot script so that I didn't have to create lower case versions of the models (user, role, roleMapping) that extend the built-in versions (User, Role, RoleMapping). I had problems creating the lower case versions (things didn't work as expected), and it's a little weird to see three lowercase models when all the others are upper case. I found other developers on the team were confused about which case to use when creating new models.

We actually added two remote methods (addRole/removeRole) to the User model. Read the comments in my code to understand the RoleMapping table. Normally you could setup the hasManyThrough relationship with your own custom models and then leverage the built-in Loopback API functions to modify those relationships, but RoleMapping is a bit different because it has an extra field (principalType) and the FK is called principalId instead of userId.

@jreeme
Copy link

jreeme commented Sep 14, 2015

I really appreciate your effort in this matter and willingness to share it.

@riteshjagga
Copy link

I added above relations as you mentioned and I am adding user using MyUser model which extends from User model. When I add a role mapping, principalId is set to NaN. Here is the code:

MyUser.create( {name: 'Ritesh Jagga', email: '[email protected]', password: 'test'}),
   function (error, user) {
    Role.findOne({where: {name: 'super'}}, function (error, role) {
      if (error) throw error;

      role.principals.create({
          principalType: RoleMapping.USER,
          principalId: user.id
      }, function (error, principal) {
          if (error) throw error;
          console.log('Added ' + user.name + ' as super user.');
     });
});

Is it due to ObjectId difference as mentioned in this issue strongloop/loopback#1441?

I am not using the hook that you added to RoleMappingwhich I believe has nothing to do with principalId being set to NaN.

Can you please help to resolve this?

Update


This was for MongoDB connector. I eventually got it working using the below code in the script file where you are defining other properties.

RoleMapping.belongsTo(MyUser);
RoleMapping.belongsTo(Role);
MyUser.hasMany(Role, {through: RoleMapping, foreignKey: 'principalId'});
MyUser.hasMany(RoleMapping, {foreignKey: 'principalId'});
Role.hasMany(MyUser, {as: 'users',  through: RoleMapping, foreignKey: 'roleId'});

Why it didn't work?

I was using base model User but should be using the extended model MyUser.

This solved the NaN issue and I was able to use include: 'roles' on the MyUser model to successfully get roles of a user.

Few more issues

I couldn't get list of users belonging to a particular role. For that I modified first 2 belongsTo relation configuration like this:

RoleMapping.belongsTo(MyUser, {foreignKey: 'principalId'});
RoleMapping.belongsTo(Role, {foreignKey: 'roleId'});

and could query successfully.

@leftclickben
Copy link

@spencermefford Thanks for this, nice work 👍

I've made a fork which has a few changes here: https://gist.github.com/leftclickben/aa3cf418312c0ffcc547

Most of my changes are purely code style, but there are two things worth pointing out:

  1. When you do if (err) cb(err); you really want to do if (err) return cb(err); (add return) otherwise the method will continue and strange things may happen. For example, in your User.addRole method, if the User.find comes back with an error, you will call the callback with that error, and then call it again with your own error further down. Adding the return will prevent this, and can also help prevent nesting blocks too deeply.
  2. This is more opinion on API design than an actual problem: I changed the addRole / removeRole methods from static methods (User.addRole) to instance methods (User.prototype.addRole), and specified isStatic: false in the API config. This way you don't need the whole User.find part because the method is called on the User instance which is retrieved for you by the framework / router. That is, this inside the method refers to the instance retrieved by the id in the URL.

My version also differs in that I'm passing the role name in rather than the id, because my clients only know about names and have no canonical source for the correct id values for the roles.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment