-
Notifications
You must be signed in to change notification settings - Fork 1.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix Role.isOwner() when multiple user models #3180
Fix Role.isOwner() when multiple user models #3180
Conversation
Can one of the admins verify this patch? |
3 similar comments
Can one of the admins verify this patch? |
Can one of the admins verify this patch? |
Can one of the admins verify this patch? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@bajtos : please review additional test + fix on built-in role-resolver $owner, when using multiple user models
common/models/role.js
Outdated
var userId = context.getUserId(); | ||
Role.isOwner(modelClass, modelId, userId, callback); | ||
var user = context.getUser(); | ||
Role.isOwner(modelClass, modelId, user.id, user.principalType, callback); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
see below, fetching the userId from accessContext is no longer enough, consequently I had to introduce a getUser()
method that also returns the user's principalType, and change the signature of Role.isOwner()
to include the principalType as an optional parameter
principalType = undefined; | ||
} | ||
principalType = principalType || Principal.USER; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
while principalType is an optional parameter, it defaults internally to USER if undefined for backward compatibility
process.nextTick(function() { | ||
callback(null, matches(modelId, userId)); | ||
}); | ||
} | ||
return callback.promise; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we need now to check if the principalType is either USER (standard, single user model) or equal to user's model name (multiple user models)
common/models/role.js
Outdated
// Ensure ownerId exists and is not a function/relation | ||
if (ownerId && 'function' !== typeof ownerId) { | ||
if (callback) callback(null, matches(ownerId, userId)); | ||
return; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this whole part is not valid anymore as it relies only on comparing userIds and cannot be modified to handle principalType, we now need to directly follow belongsTo relations if any
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this part is valid when principalType === Principal.USER
, I am concerned that we will break backwards compatibility if we remove it. In 865e1c1, I added two tests to your feature branch - they are passing on master, but failing with your new implementation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I assume that you already looked at my comment here and you prefer to keep this behavior in loopback 3 despite the comments.
So unless further comments i'll put back this behavior in place when principalType === 'USER'
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am open to discuss how to handle multiple belongsTo
relations, but for the purpose of this pull request, I prefer to stay focused on fixing the issue created by the previous patch, i.e. "allowing users from different models but with the same id), because the Role.isOwner()
method was not checking the user's principalType."
if (principalType === Principal.USER || principalType === userModelName) { | ||
debug('Checking relation %s to %s: %j', r, userModelName, rel); | ||
inst[r](processRelatedUser); | ||
return; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
again, principalType must either be equal to USER, or to related user model name
lib/access-context.js
Outdated
} | ||
} | ||
return null; | ||
return {}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
turned the getUserId
method in getUser
, now returning an object of the like {id: p.id, principalType: p.type}
of course the getUserId
is kept for backward compatibility just a few lines above, in a shortened form, reusing the getUser
method
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should return null
when there is no user associated with this AccessContext, to make it easy for the consumers of this API to detect this situation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if we do this, then this will break the getUserId()
method for which is seems normal to return null according to the tests. Should we then modify the getUserId method to return null when getUser() returns null?
If so, then there's not much need to not return {}
in the first place
expect(user).to.eql({ | ||
id: userFromOneModel.id, | ||
principalType: OneUser.modelName, | ||
}); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
adapted new tests to check getUser instead of getUserId
expect(user).to.eql({ | ||
id: userFromOneModel.id, | ||
principalType: OneUser.modelName, | ||
}); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
same here
expect(user).to.eql({ | ||
id: userFromThirdModel.id, | ||
principalType: ThirdUser.modelName, | ||
}); | ||
}); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
same here
return Role.isInRole(request.role, request.context); | ||
}); | ||
return Promise.all(isInRoles); | ||
} | ||
}); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
adjusted the test on built-in $owner role resolver to verify a user with similar id than an authorized user userOne but from another user model is denied access to album instance owned by userOne
6fd3ab2
to
bc1e1e6
Compare
common/models/role.js
Outdated
var userModelName = rel.modelTo.modelName; | ||
if (principalType === Principal.USER || principalType === userModelName) { | ||
debug('Checking relation %s to %s: %j', r, userModelName, rel); | ||
relWithUsers.push(r); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
collecting related users rather than checking them right away : there might be several belongsTo relations linking the model instance to users, rejecting the role resolver at the first returned falsy is not right
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have two concerns about this change, I have expressed them in #3140:
- backwards compatibility - with the change in place, our ACL system will start accepting request that are rejected in existing LB versions
- what if a model has a
belongsTo
relation to a User-like model, but this relation does not create ownership?
Can we leave this change out of scope of this pull request please?
common/models/role.js
Outdated
debug('User found: %j (through %j)', user.id, r); | ||
return cb(null, matches(user.id, userId)); | ||
} | ||
cb(null, false); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
several things to note :
- i chose asyncSeries so not to spam the db but it's clearly a tradeoff to make between performance and load here
- i chose to handle errors in processRelatedUser as rejecting the overall async.someSeries: we could just callback them falsy, thoughts ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i chose asyncSeries so not to spam the db but it's clearly a tradeoff to make between performance and load here
Yeah, it's a tradeoff and I don't have a strong opinion on which side is better. I am fine to use asyncSeries
here.
i chose to handle errors in processRelatedUser as rejecting the overall async.someSeries: we could just callback them falsy, thoughts ?
I think the question is what to do when we run let's say 4 queries, one of them fails, two returns information that the user is not owner, and one says that the user is owner. Should we fail the whole request because of the error? Should we ignore the error and process the request, because we know that the user is authorized,
despite the error?
I think I like the current version better:
- When there is an error, it's better to let the user and app administrators know about the problem
- We can relax this behaviour later in the future if needed. Going the other way, throwing an error in situation that used to work, would be a semver-major change IMO.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please confirm: I understand that in the following case :
4 queries, one of them fails, two returns information that the user is not owner, and one says that the user is owner
- you propose we resolve isOwner
truthy
- let the app admins know about the error -> log an error msg in the console ?
- let the app users know about the error -> how ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry for the confusion. What I meant is that your current implementation is good.
- isOwner fails with an error
- error-handling middleware reports this error e.g. to console.error
- the app user sees that their request failed
Unless I am misunderstanding the code? Can you add a unit test executing this scenario and ensuring LoopBack behaves as we expect it to?
Having said that, this discussion is out of scope of this patch if we agree to keep current handling of "belongsTo" relations.
bc1e1e6
to
03354cc
Compare
@slnode ok to test |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great, thank you @ebarault for this follow-up pull request!
You are on the right path, I left few comments to address or discuss:
common/models/role.js
Outdated
|
||
// No userId is present | ||
if (!userId) { | ||
process.nextTick(function() { | ||
callback(null, false); | ||
callback(null); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there any particular reason for changing the result from false
to undefined
? I think it may be better to preserve callback(null, false)
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
my bad, there's no reason why to change this
common/models/role.js
Outdated
// Ensure ownerId exists and is not a function/relation | ||
if (ownerId && 'function' !== typeof ownerId) { | ||
if (callback) callback(null, matches(ownerId, userId)); | ||
return; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this part is valid when principalType === Principal.USER
, I am concerned that we will break backwards compatibility if we remove it. In 865e1c1, I added two tests to your feature branch - they are passing on master, but failing with your new implementation.
return callback.promise; | ||
} | ||
|
||
modelClass.findById(modelId, function(err, inst) { | ||
if (err || !inst) { | ||
debug('Model not found for id %j', modelId); | ||
if (callback) callback(err, false); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ditto, I think it's better to keep the second argument as false
? This can happen when err
is falsy and the second condition !inst
was triggered.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hmm, too quickly assumed err wasn't ever falsy in that case (overlooked the !inst
)
common/models/role.js
Outdated
var userModelName = rel.modelTo.modelName; | ||
if (principalType === Principal.USER || principalType === userModelName) { | ||
debug('Checking relation %s to %s: %j', r, userModelName, rel); | ||
relWithUsers.push(r); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you please rewrite this loop using Array.prototype.filter?
var relWithUsers = modelClass.relations.filter(r => {
const rel = modelClass.relations[r];
// ...
});
``
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Based on our recent discussions, I prefer to leave this change out of scope of this pull request, so that we don't mix several changes into one patch.
common/models/role.js
Outdated
|
||
function processRelation(r, cb) { | ||
inst[r](processRelatedUser); | ||
function processRelatedUser(err, user) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpic:
inst[r](function processRelatedUser(err, user) {
// ...
});
common/models/role.js
Outdated
debug('User found: %j (through %j)', user.id, r); | ||
return cb(null, matches(user.id, userId)); | ||
} | ||
cb(null, false); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i chose asyncSeries so not to spam the db but it's clearly a tradeoff to make between performance and load here
Yeah, it's a tradeoff and I don't have a strong opinion on which side is better. I am fine to use asyncSeries
here.
i chose to handle errors in processRelatedUser as rejecting the overall async.someSeries: we could just callback them falsy, thoughts ?
I think the question is what to do when we run let's say 4 queries, one of them fails, two returns information that the user is not owner, and one says that the user is owner. Should we fail the whole request because of the error? Should we ignore the error and process the request, because we know that the user is authorized,
despite the error?
I think I like the current version better:
- When there is an error, it's better to let the user and app administrators know about the problem
- We can relax this behaviour later in the future if needed. Going the other way, throwing an error in situation that used to work, would be a semver-major change IMO.
lib/access-context.js
Outdated
} | ||
} | ||
return null; | ||
return {}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should return null
when there is no user associated with this AccessContext, to make it easy for the consumers of this API to detect this situation.
.then(function(isInRole) { | ||
expect(isInRole).to.be.true(); | ||
.then(function(data) { | ||
expect(data).to.eql([true, false]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This assertion produces an error message that's difficult to understand without reading the source code of the test. Can you please split the test into two, where each tests asserts a single scenario (valid context, invalid context)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@bajtos : some comments/answers needed back before i move on. Thanks
common/models/role.js
Outdated
|
||
// No userId is present | ||
if (!userId) { | ||
process.nextTick(function() { | ||
callback(null, false); | ||
callback(null); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
my bad, there's no reason why to change this
return callback.promise; | ||
} | ||
|
||
modelClass.findById(modelId, function(err, inst) { | ||
if (err || !inst) { | ||
debug('Model not found for id %j', modelId); | ||
if (callback) callback(err, false); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hmm, too quickly assumed err wasn't ever falsy in that case (overlooked the !inst
)
common/models/role.js
Outdated
// Ensure ownerId exists and is not a function/relation | ||
if (ownerId && 'function' !== typeof ownerId) { | ||
if (callback) callback(null, matches(ownerId, userId)); | ||
return; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I assume that you already looked at my comment here and you prefer to keep this behavior in loopback 3 despite the comments.
So unless further comments i'll put back this behavior in place when principalType === 'USER'
common/models/role.js
Outdated
var userModelName = rel.modelTo.modelName; | ||
if (principalType === Principal.USER || principalType === userModelName) { | ||
debug('Checking relation %s to %s: %j', r, userModelName, rel); | ||
relWithUsers.push(r); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
common/models/role.js
Outdated
debug('User found: %j (through %j)', user.id, r); | ||
return cb(null, matches(user.id, userId)); | ||
} | ||
cb(null, false); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please confirm: I understand that in the following case :
4 queries, one of them fails, two returns information that the user is not owner, and one says that the user is owner
- you propose we resolve isOwner
truthy
- let the app admins know about the error -> log an error msg in the console ?
- let the app users know about the error -> how ?
.then(function(isInRole) { | ||
expect(isInRole).to.be.true(); | ||
.then(function(data) { | ||
expect(data).to.eql([true, false]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
@bajtos : please review my comments so i can finalize the PR |
@ebarault sorry for the delay, I read your comments only now. I'll try to review and come back to you by the end of this week. |
9787025
to
a885e9e
Compare
common/models/role.js
Outdated
// checking related user | ||
var userModelName = rel.modelTo.modelName; | ||
if (principalType === Principal.USER || principalType === userModelName) { | ||
debug('Checking relation %s to %s: %j', r, userModelName, rel); | ||
inst[r](processRelatedUser); | ||
return; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we now need this additional check
if (principalType === Principal.USER || principalType === userModelName) {...
to make sure we don't follow a belongTo relation towards an incorrect user model.
ps. in order to keep the level of indent controlled, i chose to first continue
if rel.type !== 'belongsTo' && !isUserClass(rel.modelTo)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The negation of rel.type === 'belongsTo' && isUserClass(rel.modelTo)
is rel.type !== 'belongsTo' || !isUserClass(rel.modelTo)
, I'll fix this myself.
fddadd4
to
d732a83
Compare
var user = context.getUser(); | ||
var userId = user && user.id; | ||
var principalType = user && user.principalType; | ||
Role.isOwner(modelClass, modelId, userId, principalType, callback); | ||
}); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
now that context.getUser() can return null, we need to test user before getting user.id and user.principalType
} | ||
|
||
// or permit to resolve a valid user model | ||
var userModel = this.registry.findModel(p.type); | ||
if (!userModel) continue; | ||
if (userModel.prototype instanceof BaseUser) { | ||
return p.id; | ||
return {id: p.id, principalType: p.type}; | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
context.getUser()
now returns null
instead of {}
to enable developers to more easily detect issues when accessContext misses the user
var user = this.getUser(); | ||
return user && user.id; | ||
}; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
shrinked version of getUserId()
, leveraging getUser()
return Role.isInRole(Role.OWNER, invalidContext); | ||
}) | ||
.then(function(isOwner) { | ||
expect(isOwner).to.be.false(); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
split the OWNER test in 2 to check apart if resolver returns false when principalType is incorrect
@bajtos : i reverted the PR to only cover the required fix as discussed, and did the required modifications following your code review. You'll still have to squash the 2 commits to merge the tests you previously added. Thanks for carrying this over. |
Fix `Role.isOwner()` to check both principalId and principalType. This fixes a bug where users from different User model were treated as owners as long as their user id was the same as owner's id.
d732a83
to
f0e70dd
Compare
common/models/role.js
Outdated
// checking related user | ||
var userModelName = rel.modelTo.modelName; | ||
if (principalType === Principal.USER || principalType === userModelName) { | ||
debug('Checking relation %s to %s: %j', r, userModelName, rel); | ||
inst[r](processRelatedUser); | ||
return; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The negation of rel.type === 'belongsTo' && isUserClass(rel.modelTo)
is rel.type !== 'belongsTo' || !isUserClass(rel.modelTo)
, I'll fix this myself.
I made few last formatting improvements and landed the patch. Most importantly, I added more details to the commit message to explain the changes, I would appreciate if you could do that yourself in the future, @ebarault. Thank you for the contribution! 🙇 |
@bajtos : thanks. Yes i will take care of detailed commit messages myself in the future. See you soon. |
Description
When merging #2971 which i authored, @bajtos noted a missing test on the built-in role resolver
$owner
which internally calls theRole.isOwner()
method (see here)The added test actually failed, allowing users from different models but with the same id), because the
Role.isOwner()
method was not checking the user's principalType.(good catch @bajtos 👍 )
This PR fixes this issue (and adds the corresponding test)
see the inlined comment for detailed code review
Related PR
#2971 (merged)
#3140 (in progress, will be impacted)
++ any PR/issue where someone would try to iterate recursively along belongTo relations up to the owning user (need to find those)
Checklist
guide