-
-
Notifications
You must be signed in to change notification settings - Fork 337
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
Extract Groups management from HomeDBManager #1342
Merged
Merged
Changes from 1 commit
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,275 @@ | ||
import { Group } from "app/gen-server/entity/Group"; | ||
import { GroupDescriptor, NonGuestGroup, Resource } from "app/gen-server/lib/homedb/Interfaces"; | ||
import * as roles from 'app/common/roles'; | ||
import { Permissions } from 'app/gen-server/lib/Permissions'; | ||
import { User } from "app/gen-server/entity/User"; | ||
import { Organization } from "app/gen-server/entity/Organization"; | ||
import { Workspace } from "app/gen-server/entity/Workspace"; | ||
import { Document } from "app/gen-server/entity/Document"; | ||
import { AclRule } from "app/gen-server/entity/AclRule"; | ||
|
||
import { EntityManager } from "typeorm"; | ||
|
||
/** | ||
* Class responsible for Groups and Roles Management. | ||
* | ||
* It's only meant to be used by HomeDBManager. If you want to use one of its (instance or static) methods, | ||
* please make an indirection which passes through HomeDBManager. | ||
*/ | ||
export class GroupsManager { | ||
// All groups. | ||
public get defaultGroups(): GroupDescriptor[] { | ||
return this._defaultGroups; | ||
} | ||
|
||
// Groups whose permissions are inherited from parent resource to child resources. | ||
public get defaultBasicGroups(): GroupDescriptor[] { | ||
return this._defaultGroups | ||
.filter(_grpDesc => _grpDesc.nestParent); | ||
} | ||
|
||
// Groups that are common to all resources. | ||
public get defaultCommonGroups(): GroupDescriptor[] { | ||
return this._defaultGroups | ||
.filter(_grpDesc => !_grpDesc.orgOnly); | ||
} | ||
|
||
public get defaultGroupNames(): roles.Role[] { | ||
return this._defaultGroups.map(_grpDesc => _grpDesc.name); | ||
} | ||
|
||
public get defaultBasicGroupNames(): roles.BasicRole[] { | ||
return this.defaultBasicGroups | ||
.map(_grpDesc => _grpDesc.name) as roles.BasicRole[]; | ||
} | ||
|
||
public get defaultNonGuestGroupNames(): roles.NonGuestRole[] { | ||
return this._defaultGroups | ||
.filter(_grpDesc => _grpDesc.name !== roles.GUEST) | ||
.map(_grpDesc => _grpDesc.name) as roles.NonGuestRole[]; | ||
} | ||
|
||
public get defaultCommonGroupNames(): roles.NonMemberRole[] { | ||
return this.defaultCommonGroups | ||
.map(_grpDesc => _grpDesc.name) as roles.NonMemberRole[]; | ||
} | ||
|
||
// Returns a map of userIds to the user's strongest default role on the given resource. | ||
// The resource's aclRules, groups, and memberUsers must be populated. | ||
public static getMemberUserRoles<T extends roles.Role>(res: Resource, allowRoles: T[]): {[userId: string]: T} { | ||
// Add the users to a map to ensure uniqueness. (A user may be present in | ||
// more than one group) | ||
const userMap: {[userId: string]: T} = {}; | ||
(res.aclRules as AclRule[]).forEach((aclRule: AclRule) => { | ||
const role = aclRule.group.name as T; | ||
if (allowRoles.includes(role)) { | ||
// Map the users to remove sensitive information from the result and | ||
// to add the group names. | ||
aclRule.group.memberUsers.forEach((u: User) => { | ||
// If the user is already present in another group, use the more | ||
// powerful role name. | ||
userMap[u.id] = userMap[u.id] ? roles.getStrongestRole(userMap[u.id], role) : role; | ||
}); | ||
} | ||
}); | ||
return userMap; | ||
} | ||
|
||
/** | ||
* Five aclRules, each with one group (with the names 'owners', 'editors', 'viewers', | ||
* 'guests', and 'members') are created by default on every new entity (Organization, | ||
* Workspace, Document). These special groups are documented in the _defaultGroups | ||
* constant below. | ||
* | ||
* When a child resource is created under a parent (i.e. when a new Workspace is created | ||
* under an Organization), special groups with a truthy 'nestParent' property are set up | ||
* to include in their memberGroups a single group on initialization - the parent's | ||
* corresponding special group. Special groups with a falsy 'nextParent' property are | ||
* empty on intialization. | ||
* | ||
* NOTE: The groups are ordered from most to least permissive, and should remain that way. | ||
* TODO: app/common/roles already contains an ordering of the default roles. Usage should | ||
* be consolidated. | ||
*/ | ||
private readonly _defaultGroups: GroupDescriptor[] = [{ | ||
name: roles.OWNER, | ||
permissions: Permissions.OWNER, | ||
nestParent: true | ||
}, { | ||
name: roles.EDITOR, | ||
permissions: Permissions.EDITOR, | ||
nestParent: true | ||
}, { | ||
name: roles.VIEWER, | ||
permissions: Permissions.VIEW, | ||
nestParent: true | ||
}, { | ||
name: roles.GUEST, | ||
permissions: Permissions.VIEW, | ||
nestParent: false | ||
}, { | ||
name: roles.MEMBER, | ||
permissions: Permissions.VIEW, | ||
nestParent: false, | ||
orgOnly: true | ||
}]; | ||
|
||
/** | ||
* Helper for adjusting acl inheritance rules. Given an array of top-level groups from the | ||
* resource of interest, and an array of inherited groups belonging to the parent resource, | ||
* moves the inherited groups to the group with the destination name or lower, if their | ||
* permission level is lower. If the destination group name is omitted, the groups are | ||
* moved to their original inheritance locations. If the destination group name is null, | ||
* the groups are all removed and there is no access inheritance to this resource. | ||
* Returns the updated array of top-level groups. These returned groups should be saved | ||
* to update the group inheritance in the database. | ||
* | ||
* For all passed-in groups, their .memberGroups will be reset. For | ||
* the basic roles (owner | editor | viewer), these will get updated | ||
* to include inheritedGroups, with roles reduced to dest when dest | ||
* is given. All of the basic roles must be present among | ||
* groups. Any non-basic roles present among inheritedGroups will be | ||
* ignored. | ||
* | ||
* Does not modify inheritedGroups. | ||
*/ | ||
public moveInheritedGroups( | ||
groups: NonGuestGroup[], inheritedGroups: Group[], dest?: roles.BasicRole|null | ||
): void { | ||
// Limit scope to those inheritedGroups that have basic roles (viewers, editors, owners). | ||
inheritedGroups = inheritedGroups.filter(group => roles.isBasicRole(group.name)); | ||
|
||
// NOTE that the special names constant is ordered from least to most permissive. | ||
const reverseDefaultNames = this.defaultBasicGroupNames.reverse(); | ||
|
||
// The destination must be a reserved inheritance group or null. | ||
if (dest && !reverseDefaultNames.includes(dest)) { | ||
throw new Error('moveInheritedGroups called with invalid destination name'); | ||
} | ||
|
||
// Mapping from group names to top-level groups | ||
const topGroups: {[groupName: string]: NonGuestGroup} = {}; | ||
groups.forEach(grp => { | ||
// Note that this has a side effect of initializing the memberGroups arrays. | ||
grp.memberGroups = []; | ||
topGroups[grp.name] = grp; | ||
}); | ||
|
||
// The destFunc maps from an inherited group to its required top-level group name. | ||
const destFunc = (inherited: Group) => | ||
dest === null ? null : reverseDefaultNames.find(sp => sp === inherited.name || sp === dest); | ||
|
||
// Place inherited groups (this has the side-effect of updating member groups) | ||
inheritedGroups.forEach(grp => { | ||
if (!roles.isBasicRole(grp.name)) { | ||
// We filtered out such groups at the start of this method, but just in case... | ||
throw new Error(`${grp.name} is not an inheritable group`); | ||
} | ||
const moveTo = destFunc(grp); | ||
if (moveTo) { | ||
topGroups[moveTo].memberGroups.push(grp); | ||
} | ||
}); | ||
} | ||
|
||
/** | ||
* Update the set of users in a group. TypeORM's .save() method appears to be | ||
* unreliable for a ManyToMany relation with a table with a multi-column primary | ||
* key, so we make the update using explicit deletes and inserts. | ||
*/ | ||
public async setGroupUsers(manager: EntityManager, groupId: number, usersBefore: User[], | ||
usersAfter: User[]) { | ||
const userIdsBefore = new Set(usersBefore.map(u => u.id)); | ||
const userIdsAfter = new Set(usersAfter.map(u => u.id)); | ||
const toDelete = [...userIdsBefore].filter(id => !userIdsAfter.has(id)); | ||
const toAdd = [...userIdsAfter].filter(id => !userIdsBefore.has(id)); | ||
if (toDelete.length > 0) { | ||
await manager.createQueryBuilder() | ||
.delete() | ||
.from('group_users') | ||
.whereInIds(toDelete.map(id => ({user_id: id, group_id: groupId}))) | ||
.execute(); | ||
} | ||
if (toAdd.length > 0) { | ||
await manager.createQueryBuilder() | ||
.insert() | ||
// Since we are adding new records in group_users, we may get a duplicate key error if two documents | ||
// are added at the same time (even in transaction, since we are not blocking the whole table). | ||
.orIgnore() | ||
.into('group_users') | ||
.values(toAdd.map(id => ({user_id: id, group_id: groupId}))) | ||
.execute(); | ||
} | ||
} | ||
|
||
/** | ||
* Returns a name to group mapping for the standard groups. Useful when adding a new child | ||
* entity. Finds and includes the correct parent groups as member groups. | ||
*/ | ||
public createGroups(inherit?: Organization|Workspace, ownerId?: number): {[name: string]: Group} { | ||
const groupMap: {[name: string]: Group} = {}; | ||
this.defaultGroups.forEach(groupProps => { | ||
if (!groupProps.orgOnly || !inherit) { | ||
// Skip this group if it's an org only group and the resource inherits from a parent. | ||
const group = new Group(); | ||
group.name = groupProps.name; | ||
if (inherit) { | ||
this.setInheritance(group, inherit); | ||
} | ||
groupMap[groupProps.name] = group; | ||
} | ||
}); | ||
// Add the owner explicitly to the owner group. | ||
if (ownerId) { | ||
const ownerGroup = groupMap[roles.OWNER]; | ||
const user = new User(); | ||
user.id = ownerId; | ||
ownerGroup.memberUsers = [user]; | ||
} | ||
return groupMap; | ||
} | ||
|
||
// Sets the given group to inherit the groups in the given parent resource. | ||
public setInheritance(group: Group, parent: Organization|Workspace) { | ||
// Add the parent groups to the group | ||
const groupProps = this.defaultGroups.find(special => special.name === group.name); | ||
if (!groupProps) { | ||
throw new Error(`Non-standard group passed to _addInheritance: ${group.name}`); | ||
} | ||
if (groupProps.nestParent) { | ||
const parentGroups = (parent.aclRules as AclRule[]).map((_aclRule: AclRule) => _aclRule.group); | ||
const inheritGroup = parentGroups.find((_parentGroup: Group) => _parentGroup.name === group.name); | ||
if (!inheritGroup) { | ||
throw new Error(`Special group ${group.name} not found in ${parent.name} for inheritance`); | ||
} | ||
group.memberGroups = [inheritGroup]; | ||
} | ||
} | ||
|
||
// Returns the most permissive default role that does not have more permissions than the passed | ||
// in argument. | ||
public getRoleFromPermissions(permissions: number): roles.Role|null { | ||
permissions &= ~Permissions.PUBLIC; // tslint:disable-line:no-bitwise | ||
const group = this.defaultBasicGroups.find(grp => | ||
(permissions & grp.permissions) === grp.permissions); // tslint:disable-line:no-bitwise | ||
return group ? group.name : null; | ||
} | ||
|
||
// Returns the maxInheritedRole group name set on a resource. | ||
// The resource's aclRules, groups, and memberGroups must be populated. | ||
public getMaxInheritedRole(res: Workspace|Document): roles.BasicRole|null { | ||
const groups = (res.aclRules as AclRule[]).map((_aclRule: AclRule) => _aclRule.group); | ||
let maxInheritedRole: roles.NonGuestRole|null = null; | ||
for (const name of this.defaultBasicGroupNames) { | ||
const group = groups.find(_grp => _grp.name === name); | ||
if (!group) { | ||
throw new Error(`Error in _getMaxInheritedRole: group ${name} not found in ${res.name}`); | ||
} | ||
if (group.memberGroups.length > 0) { | ||
maxInheritedRole = name; | ||
break; | ||
} | ||
} | ||
return roles.getEffectiveRole(maxInheritedRole); | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
Ideally imports are ordered alphabetically, and use of quotes are consistent. Sorry we don't have automatic linting for this.
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.
@paulfitz I am never really sure about how to sort the imports. How should we sort this line:
import { GroupDescriptor, NonGuestGroup, Resource } from "app/gen-server/lib/homedb/Interfaces";
Should we consider the imported object (
GroupDescriptor
) or the path ("app/gen-server/lib/homedb/Interfaces"
) for sorting?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 path