I am developing a NodeJS application using Loopback.
I am pretty new to both nodejs and REST APIs, so please correct me if I am conceptually wrong.
Loopback automatically builds CRUD REST APIs, which is a feature I would like to use in order to avoid to write APIs by myself, but I need to limit users to be able to see only their data.
For example, imagine there are 3 tables in my DB, user
, book
and a relation table user_book
.
So for example:
table user
id | name
---------
1 | user1
2 | user2
3 | user3
table book
id | title | author
-------------------
1 | title1 | author1
2 | title2 | author1
3 | title3 | author2
4 | title4 | author2
5 | title5 | author3
table user_book
id | user_id | book_id
-------------------
1 | 1 | 1
2 | 1 | 4
3 | 1 | 3
4 | 2 | 3
5 | 2 | 2
6 | 2 | 1
7 | 3 | 3
When a user X
is authenticated, the API /books
should answer with ONLY X's books, and not every book in the table. For example, if user user1
is logged and calls /books
, he get should only get his books, so books with id 1, 3, 4
.
Similarly, /books?filter[where][book_author]='author1'
should return only books of user X
whose author is 'author1'.
I found out that loopback offers remote hooks to attach before and after the execution of a remote method, and also offers so called scopes to
[...]specify commonly-used queries that you can reference as method calls on a model[...]
I was thinking about using a combination of the 2 in order to limit access to the table books
to only rows of the user that runs calls the API.
module.exports = function (book) {
// before every operation on table book
book.beforeRemote('**', function (ctx, user, next) {
[HERE I WOULD PERFORM A QUERY TO FIND THE BOOKS ASSOCIATED WITH THE USER, LET'S CALL ID book_list]
ctx._ds = book.defaultScope; // save the default scope
book.defaultScope = function () {
return {
'where': {
id in book_list
}
};
};
next();
});
book.afterRemote('**', function (ctx, user, next) {
book.defaultScope = ctx._ds; // restore the default scope
next();
});
};
Would this solution work? In particular, I am particularly concerned about concurrency. If multiple requests happen for /books
from different users, would changing the default scope be a critical operation?
The way we accomplished this was to create a mixin. Have a look at the loopback timestamp mixing in github. I would recommend the mixing create an "owner" relation to your user model. Here's how it works in a nutshell:
/common/mixins/owner.js
/common/models/book.json
Every time you use the Owner mixing, that model will automatically have a ownerId property added and filled each time a new instance is created or saved and the results will automatically be filtered each time you "get" the data.
I think the solution is using loopback relation. You must set the relation: - User has many book through user book - Book has many user through user book
It is similar to this example provided by loopback documentation: loopback docs
So let's say user should be authenticated before using the function, then you can pass user/userId/books to get books accessible by specific user.
If you want to limit the access, then you should use ACL. For this case, you must use custom role resolver, the same example is provided by loopback: roleResolver
If you applied this resolver, then user can only access the books that belongs to them.
Hope this helps
Use this mixim instead of @YeeHaw1234 answer . All other steps are same.
I would like to add to YeeHaw1234's answer. I plan to use Mixins the way he describes, but I needed more fields than just User ID to filter the data. I have 3 other fields that I wanted to add to the Access token so I could enforce data rules at the lowest possible level.
I wanted to add some fields to the session, but couldn't figure out how in Loopback. I looked at express-session and cookie-express, but the problem was that I did not want to rewrite the Loopback login and Login seemed like the place where the session fields should be set.
My solution was to create a Custom User and Custom Access Token and add the fields I needed. I then used an operation hook (before save) to insert my new fields before a new Access Token was written.
Now every time someone logs in, I get my extra fields. Feel free to let me know if there is an easier way to add fields to a session. I plan to add an update Access Token so that if the user's permissions change while they are logged in that they will see those changes in the session.
Here is some of the code.
/common/models/mr-access-token.js
This took me a long time to debug. Here was a few hurdles I had. I initially thought it needed to be in /server/boot, but I wasn't seeing the code triggered on saves. When I moved it to /common/models it started firing. Trying to figure out how to reference a 2nd model from within the observer wasn't in the docs. The
var app = ...
was in another SO answer. The last big problem was I had thenext()
outside the async findById so the instance was being returned unchanged and then the async code would modify the value./common/models/mr-user.js
/common/models/mr-user.js
/server/boot/mrUserRemoteMethods.js
This is straight from the examples, but it wasn't clear that it should be registered in /boot. I couldn't get my custom user to send emails until I moved it from /common/models to /server/boot.
Here is my solution to your problem:
/common/models/user_book.json
/common/models/book
/common/models/user.json
Then in the user model js file, you need to create a customized remote method with HTTP verb "GET" and has route "/books". In its handling function, you should obtain the authenticated user instance (with the access token information) and just return user.books (implemented by loopback for the through relation) to obtain its related books specified by the user_book model. Here is the code example:
/common/models/user.js
Please also make sure the remote methods are exposed for public access:
/server/model-config.json:
With these, you should be able to call
GET /users/books?access_token=[authenticated token obtained from POST /users/login]
to obtain the list of books belonging to the authenticated user. See references for the use of has-many-through relation in loopback: https://loopback.io/doc/en/lb3/HasManyThrough-relations.htmlGood luck! :)