LoopBack enables you to define both static and dynamic roles. Static roles are stored in a data source and are mapped to users. In contrast, dynamic roles aren’t assigned to users and are determined during access.
Static roles
Here is an example defining a new static role and assigning a user to that role.
/server/boot/script.js
User.create([
{username: 'John', email: 'john@doe.com', password: 'opensesame'},
{username: 'Jane', email: 'jane@doe.com', password: 'opensesame'},
{username: 'Bob', email: 'bob@projects.com', password: 'opensesame'}
], function(err, users) {
if (err) return cb(err);
//create the admin role
Role.create({
name: 'admin'
}, function(err, role) {
if (err) cb(err);
//make bob an admin
role.principals.create({
principalType: RoleMapping.USER,
principalId: users[2].id
}, function(err, principal) {
cb(err);
});
});
});
Now you can use the role defined above in the access controls. For example, add the following to common/models/project.json to enable users in the “admin” role to call all REST APIs.
/common/models/model.json
{
"accessType": "EXECUTE",
"principalType": "ROLE",
"principalId": "admin",
"permission": "ALLOW",
"property": "find"
}
Dynamic roles
Sometimes static roles aren’t flexible enough. LoopBack also enables you to define dynamic roles that are defined at run-time.
LoopBack provides the following built-in dynamic roles.
| Role object property | String value | Description |
|---|---|---|
| Role.OWNER | $owner | Owner of the object |
| Role.AUTHENTICATED | $authenticated | authenticated user |
| Role.UNAUTHENTICATED | $unauthenticated | Unauthenticated user |
| Role.EVERYONE | $everyone | Everyone |
The first example used the “$owner” dynamic role to allow access to the owner of the requested project model.
Note:
To qualify a $owner, the target model needs to have a belongsTo relation to the User model (or a model that extends User)
and property matching the foreign key of the target model instance.
The check for $owner is performed only for a remote method that has ‘:id’ on the path, for example, GET /api/users/:id.
Use Role.registerResolver()
to set up a custom role handler in a boot script.
This function takes two parameters:
- String name of the role in question.
- Function that determines if a principal is in the specified role.
The function signature must be
function(role, context, callback).
For example, here is the role resolver from loopback-example-access-control:
/server/boot/role-resolver.js
module.exports = function(app) {
var Role = app.models.Role;
Role.registerResolver('teamMember', function(role, context, cb) {
// Q: Is the current request accessing a Project?
if (context.modelName !== 'project') {
// A: No. This role is only for projects: callback with FALSE
return process.nextTick(() => cb(null, false));
}
//Q: Is the user logged in? (there will be an accessToken with an ID if so)
var userId = context.accessToken.userId;
if (!userId) {
//A: No, user is NOT logged in: callback with FALSE
return process.nextTick(() => cb(null, false));
}
// Q: Is the current logged-in user associated with this Project?
// Step 1: lookup the requested project
context.model.findById(context.modelId, function(err, project) {
// A: The datastore produced an error! Pass error to callback
if(err) return cb(err);
// A: There's no project by this ID! Pass error to callback
if(!project) return cb(new Error("Project not found"));
// Step 2: check if User is part of the Team associated with this Project
// (using count() because we only want to know if such a record exists)
var Team = app.models.Team;
Team.count({
ownerId: project.ownerId,
memberId: userId
}, function(err, count) {
// A: The datastore produced an error! Pass error to callback
if (err) return cb(err);
if(count > 0){
// A: YES. At least one Team associated with this User AND Project
// callback with TRUE, user is role:`teamMember`
return cb(null, true);
}
else{
// A: NO, User is not in this Project's Team
// callback with FALSE, user is NOT role:`teamMember`
return cb(null, false);
}
});
});
});
};
Note:
Why does the code above wrap some callback invocations in process.nextTick(()=> cb(...)), but not others.
In asynchronous functions like this one that take a callback and then pass results to it at a later time, you must call the callback at a later time, not right away (synchronously).
Call the callback from a function passed to process.nextTick in places where it would otherwise be called synchronously.
Calls from the findById or count callbacks are already guaranteed to happen at a later time as they access the database, an asynchronous operation, so we don’t need to wrap those calls in process.nextTick.
For more information, see Designing APIs for Asynchrony.
Using the dynamic role defined above, we can restrict access of project information to users that are team members of the project.
/common/models/model.json
{
"accessType": "READ",
"principalType": "ROLE",
"principalId": "teamMember",
"permission": "ALLOW",
"property": "findById"
}
REVIEW COMMENT from Rand
I assume that you could define any number of role resolvers in a single boot script. Is that true?
Need some more explanation:
- What is
contextand where does it come from?- is an object provided by loopback to give the user context into the request (ie. when the request comes in, they can access context.req or context.res, like you normally would with middleware)
- What is purpose of process.nextTick() ?
- this is part of node and requires a whole explanation into the heart of node, (ie the event loop). basically this delays the `cb` call until the next `tick` of the event loop, so the machinery can process all events currently in the queue before processing your callback.
- what is return
reject()and where doesreject()come from?- reject is a function we define inline ie function reject() ...
- we basically say, if request is not a a request to api/projects (by checking the modelname), do not execute the rest of the script and reject the request (do nothing).
- do this by calling "reject", which will return false during the next cycle of the event loop (returning false in the second param means the person is NOT a team member, ie is not in that role)
- The logic at the end that determines whether teamMember is in team based on Team.count() seems a bit convoluted. Explain, esp. how cb works in this specific case.
- This example is provided verbatim by raymond
- but the idea is you have a team table and you do a query to count the "rows" because the requester can be on multiple teams, so any number you get greater than 0 is ok