Skip to content
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

Roles should follow acls #4895

Closed
wants to merge 18 commits into from
Closed

Roles should follow acls #4895

wants to merge 18 commits into from

Conversation

georgesjamous
Copy link
Contributor

@georgesjamous georgesjamous commented Jul 14, 2018

This is related to #4821
Since roles are used to enforce ACL on another object, they cannot be used to enforce ACL on themselves. So the only way I could think of to do this is to fetch all roles normally and build a tree-like list and compute ACL access manually on each Role.
Code enhancements could still be done, I would like another set of eyes on this since its a security matter. Maybe another more straightforward way is available.

Reasoning:
Branch : R1 -> R2 -> R3 -> R4
This is a role tree-branch where R1 is directly accessible (user is in "users" relation).
R2 is fetched because of R2.getRoles().add(R1). R3 is fetched because of R3.getRoles().add(R2)
Computation:
In order to compute access, we need to check every node from the inside out.
R4 then R3 then R2 then R1. If at any time the cycle breaks (ie, we stumbled upon a role that is inaccessible) we throw the branch. (ie, if R2 is inaccessible, R4, R3 & R2 are rejected)
Multiple paths are computed in an Or manner. (ie, at least one path need to be ok)
R1 -> R2 -> R3 -> R4 and R6 -> R4 (for R4 we have R6 'Or' R3 -> R2 -> R1)

Caveats:
Throwing a role and be done with it is not enough, a second branch, later on, could provide us with access to a role we already rejected. A brute force operation would be to retry everything whenever a role is accepted. So in order to do this in a better way, the code will keep a reference to the role that rejected a role and will retry it explicitly when the first role is accepted.

Concerns:
Accepting this PR means that it will break some apps since Role ACLs is not being taken into consideration during "read". How about a server level configuration option to select which type of Role permissions to use "legacy" or "advanced" for example.
Or, we could set ACL by default that a role is 'read' by himself if not explicitly set to 'false' when creating a role.

@codecov
Copy link

codecov bot commented Jul 14, 2018

Codecov Report

Merging #4895 into master will decrease coverage by 0.16%.
The diff coverage is 89.62%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #4895      +/-   ##
==========================================
- Coverage    94.3%   94.14%   -0.17%     
==========================================
  Files         121      122       +1     
  Lines        8760     8845      +85     
==========================================
+ Hits         8261     8327      +66     
- Misses        499      518      +19
Impacted Files Coverage Δ
src/Auth.js 100% <100%> (ø) ⬆️
src/AuthRoles.js 89.14% <89.14%> (ø)
src/Adapters/Storage/Mongo/MongoStorageAdapter.js 91.33% <0%> (-0.75%) ⬇️
src/RestWrite.js 92.97% <0%> (-0.37%) ⬇️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 6172018...96b8ce8. Read the comment docs.

Copy link
Contributor

@flovilmart flovilmart left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR, there's a lot of inconsistencies in the style (I know it should be enforced by the eslint but somehow it isnt). Can you make it consistent, including the trailing semi-colons, spaces in if (condition) { etc...

@@ -76,71 +78,13 @@ describe('Parse Role testing', () => {
return role.save({}, { useMasterKey: true });
};

it("should not recursively load the same role multiple times", (done) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should keep that test please.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, no problem.

// For each role, fetch their sibling, what they inherit
// return with result and roleId for later comparison
const promises = [admin, moderator, contentManager, superModerator].map((role) => {
return auth._getAllRolesNamesForRoleIds([role.id]).then((result) => {
const authRoles = auth.getAuthRoles()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok so this test fails because the roles are not readable by any one so not applied

src/AuthRoles.js Outdated
for (let index = 0; index < parentIds.length; index++) {
const parentId = parentIds[index];
const parentRole = this.manifest[parentId]
if(!parentRole) continue;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we get curly brackets here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure thing

src/AuthRoles.js Outdated
* Resolves with a promise once all roles inherited by a role are fetched.
* Inherited roles are roles a single role has access to by the 'roles' relation.
*/
AuthRoles.prototype.findRolesOfRolesRecursively = function() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We prefer algorithms without side effects instead of mutations of the global object state: ie: instead of storing the properties in the global state object, store the result of each operation locally, and then return the final result after the recursion for example:

function somethingResursive(currentState = {}) {
   if (breakingCondition) {
     return currentState;
   }
   const result = doSomething():
   const mergedResult = mergeStates(currentState, result);
   return somethingResursive(mergedResult);
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, let me see what I can do about this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It’s cleaner and more testable usually :) also, please use ES6 classes now :)

src/AuthRoles.js Outdated
this.auth = auth
this.userId = auth.user ? auth.user.id : undefined
this.master = master
this.isMaster = isMaster;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the difference between those two? Why do we need both?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually i dont think we do. I am working on another branch in which I removed all these unneeded and duplicate checks.

src/AuthRoles.js Outdated
export function AuthRoles(auth: Auth, master, isMaster = false) {
this.auth = auth
this.userId = auth.user ? auth.user.id : undefined
this.master = master
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this parameter shoould likely be config or masterConfig

src/AuthRoles.js Outdated
const name = roleNames[index];
const statement = acl[name]
if(statement){
if(statement["read"] === true) return true
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please avoid those at all costs, they don't help readability nor maintainability

if (statement["read"] === true) {
    return true;
}

src/AuthRoles.js Outdated

// Or
function isAnyExplicitlyGranted(acl, roleNames){
for (let index = 0; index < roleNames.length; index++) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have actually found that its faster to loop over the ACL and look through the role names from a set (.has())
I will change it for you to review

src/AuthRoles.js Outdated
* "read" is true
* @returns {Boolean}
*/
const _isReadableAcl = (statement) => statement.read === true
Copy link
Contributor Author

@georgesjamous georgesjamous Jul 21, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@flovilmart don't know if this is needed. I don't recall if an ACL with r/w set to false can be available, because the read key all together will not be available. (ie "roleName" : { "write": true }. without read).
But I thought I would explicitly check the 'read' access.

const acl = role.ACL;
// Dont bother checking if the ACL
// is empty or corrupt
if(acl === {} || !acl){
Copy link
Contributor Author

@georgesjamous georgesjamous Jul 21, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this could happen


// 1 query for the direct roles (parent roles).
// 1 query for each role after that, including the parent role.
expect(restExecute.calls.count()).toEqual(5);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@flovilmart An increase in calls is to be expected here since for each role we have to know the parent to properly compile the tree.

Georges Jamous added 2 commits July 21, 2018 11:53
@georgesjamous
Copy link
Contributor Author

georgesjamous commented Aug 6, 2018

@flovilmart i am not sure how to request a review this point :)
my changes are complete, i just have to resolve the new conflict that appeared and that i just noticed.

@flovilmart
Copy link
Contributor

I just need to take the time to finally review it and take care of it

@georgesjamous
Copy link
Contributor Author

@flovilmart
wasn't rushing you, just letting you know about the changes and that i am aware of the conflict that arose 👍

.then(response => response.results);
}else{
const query = new Parse.Query(Parse.Role);
_.forEach(restWhere, (value, key) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@flovilmart
I wanted to add an additional constraint here, to select only needed keys, just in case more are available to limit the returned data.

 // limit returned data just in case other keys are available
 query.select(['objectId', 'ACL']);
// or simply since acl, createdAt, savedAt are selected by default
query.select('objectId');

But for some reason the query stoped returning data (no objects).
Do you have knowlege of why this could happen ? I have diregarded it since its not replated to the PR,
but was just wondering why it wouldnt

@flovilmart
Copy link
Contributor

I am worried that we’re taking the problem the wrong way. It seems that you attempt to resolve all the roles with the masterKey and then filter out the ones the user can’t read. Which is well, contrary to the promise of parse.

Why don’t we do:

  1. Get all the roles where users == user with userId, *
  2. Get all the roled where roles in found roles with aclList == concat(roles, userId, *)
  3. Recursion until no more roles are discovered

This way, this can only be consistent with the general approach, and the amount of changes will be minimal.

@georgesjamous
Copy link
Contributor Author

georgesjamous commented Aug 23, 2018

So,
If I am not mistaken, you approach is to build the ACL as we go on.
And with each iteration add(+) more roles to ACL and use it in the next iteration.

I will give it more thoughts and attempt to find a case where it won't work.
Will try to write a quick code and run it through the tests to see the results.
I remember being convinced that the only way to cover all cases is by building a tree and go branch by branch, your proposed solution seems to be more elegant tough.

@flovilmart
Copy link
Contributor

Well, if it doesn’t work, that has broader implications.

Each role, directly owning a user gives the user this roles’s powers. Using those powers we need to discover which other powers are inherited down the line. If this doesn’t work cleanly this way, we’ll go with the original suggestion of using a new ‘inactive’ Boolean but i’d rather avoid. Also if possible, can we not introduce a new AuthRoles thing as other PR’s are working on abstraction of the role module and cache

@flovilmart
Copy link
Contributor

So I ran something quickly, and it seems that the main issue that I face is when a role is self referencing:

{ objectId: 'nlrw6e2GkB',
    name: 'r1',
    createdAt: '2018-08-23T12:51:44.417Z',
    updatedAt: '2018-08-23T12:51:44.536Z',
    users: { __type: 'Relation', className: '_User' },
    roles: { __type: 'Relation', className: '_Role' },
    ACL: { 'role:r1': [Object] } }

As you can see in this example, the role can be read only by users from this role, which will probably be true for every role if the developer says that only people with this role will be able to read.

Somehow, this is consistent with the ACL strategy, If you can't read somethiung, you can't know you're in it.

@georgesjamous
Copy link
Contributor Author

The main reasoning behind your proposed solution is to skip local ACL test all together and rely soley on a list of accessible roles (aclList) to fetch the next one and so on.
This is impossible, because the thing about our problem is that
we need to follow the ACL to find something constrained by the ACL, that we do not have in the first place.

Also the query will look something like this:

query.containedIn('rprem', concat([roleNames, userid, *]))
query.notContainedIn('name', roleNames) // to prevent circles

And personally i really hate using "notContainedIn" since it may slow things down on large arrays and collections.

@georgesjamous
Copy link
Contributor Author

georgesjamous commented Aug 23, 2018

The solution i have proposed, is no less secure that what Auth.js is doing now.
It uses master key to recursevly load all roles (like we are doing write now).
All that AuthRoles.js does is build a tree and reject some roles depending on their branch.

@georgesjamous
Copy link
Contributor Author

georgesjamous commented Aug 23, 2018

Regarding this

If this doesn’t work cleanly this way, we’ll go with the original suggestion of using a new ‘inactive’ Boolean but i’d rather avoid.

I agree with you, roles should follow ACL. Enable/Disable should be avoided. It does not solve the main problem.

@flovilmart
Copy link
Contributor

@georgesjamous well, I have a solution that removes the complete necessity of the AuthRoles and that actually is very similar to the current implementation.

I have more tests to write for it stay tuned I'll post the branch soon.

@flovilmart
Copy link
Contributor

@georgesjamous
Copy link
Contributor Author

will do

@flovilmart
Copy link
Contributor

@georgesjamous did you have a look? Anything that look wrong?

@georgesjamous
Copy link
Contributor Author

I haven't had the chance yet.
I will check it out first thing tomorrow

@georgesjamous
Copy link
Contributor Author

I am gonna go ahead and close this then since you have already implemented most changes on
Role based ACLs #5000
I dont think there is much left to do anyways, just some tests and fixes here and there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants