Index: appengine/monorail/services/usergroup_svc.py |
diff --git a/appengine/monorail/services/usergroup_svc.py b/appengine/monorail/services/usergroup_svc.py |
new file mode 100644 |
index 0000000000000000000000000000000000000000..97001908c5a1ab6708ed6127faf42b30a59fdeaa |
--- /dev/null |
+++ b/appengine/monorail/services/usergroup_svc.py |
@@ -0,0 +1,541 @@ |
+# Copyright 2016 The Chromium Authors. All rights reserved. |
+# Use of this source code is govered by a BSD-style |
+# license that can be found in the LICENSE file or at |
+# https://developers.google.com/open-source/licenses/bsd |
+ |
+"""Persistence class for user groups. |
+ |
+User groups are represented in the database by: |
+- A row in the Users table giving an email address and user ID. |
+ (A "group ID" is the user_id of the group in the User table.) |
+- A row in the UserGroupSettings table giving user group settings. |
+ |
+Membership of a user X in user group Y is represented as: |
+- A row in the UserGroup table with user_id=X and group_id=Y. |
+""" |
+ |
+import collections |
+import logging |
+import re |
+ |
+from framework import permissions |
+from framework import sql |
+from proto import usergroup_pb2 |
+from services import caches |
+ |
+ |
+USERGROUP_TABLE_NAME = 'UserGroup' |
+USERGROUPSETTINGS_TABLE_NAME = 'UserGroupSettings' |
+USERGROUPPROJECTS_TABLE_NAME = 'Group2Project' |
+ |
+USERGROUP_COLS = ['user_id', 'group_id', 'role'] |
+USERGROUPSETTINGS_COLS = ['group_id', 'who_can_view_members', |
+ 'external_group_type', 'last_sync_time'] |
+USERGROUPPROJECTS_COLS = ['group_id', 'project_id'] |
+ |
+ |
+class MembershipTwoLevelCache(caches.AbstractTwoLevelCache): |
+ """Class to manage RAM and memcache for each user's memberships.""" |
+ |
+ def __init__(self, cache_manager, usergroup_service, group_dag): |
+ super(MembershipTwoLevelCache, self).__init__( |
+ cache_manager, 'user', 'memberships:', None) |
+ self.usergroup_service = usergroup_service |
+ self.group_dag = group_dag |
+ |
+ def _DeserializeMemberships(self, memberships_rows): |
+ """Reserialize the DB results into a {user_id: {group_id}}.""" |
+ result_dict = collections.defaultdict(set) |
+ for user_id, group_id in memberships_rows: |
+ result_dict[user_id].add(group_id) |
+ |
+ return result_dict |
+ |
+ def FetchItems(self, cnxn, keys): |
+ """On RAM and memcache miss, hit the database to get memberships.""" |
+ direct_memberships_rows = self.usergroup_service.usergroup_tbl.Select( |
+ cnxn, cols=['user_id', 'group_id'], distinct=True, |
+ user_id=keys) |
+ memberships_set = set() |
+ for c_id, p_id in direct_memberships_rows: |
+ all_parents = self.group_dag.GetAllAncestors(cnxn, p_id, True) |
+ all_parents.append(p_id) |
+ memberships_set.update([(c_id, g_id) for g_id in all_parents]) |
+ retrieved_dict = self._DeserializeMemberships(list(memberships_set)) |
+ |
+ # Make sure that every requested user is in the result, and gets cached. |
+ retrieved_dict.update( |
+ (user_id, set()) for user_id in keys |
+ if user_id not in retrieved_dict) |
+ return retrieved_dict |
+ |
+ |
+class UserGroupService(object): |
+ """The persistence layer for user group data.""" |
+ |
+ def __init__(self, cache_manager): |
+ """Initialize this service so that it is ready to use. |
+ |
+ Args: |
+ cache_manager: local cache with distributed invalidation. |
+ """ |
+ self.usergroup_tbl = sql.SQLTableManager(USERGROUP_TABLE_NAME) |
+ self.usergroupsettings_tbl = sql.SQLTableManager( |
+ USERGROUPSETTINGS_TABLE_NAME) |
+ self.usergroupprojects_tbl = sql.SQLTableManager( |
+ USERGROUPPROJECTS_TABLE_NAME) |
+ |
+ self.group_dag = UserGroupDAG(self) |
+ |
+ # Like a dictionary {user_id: {group_id}} |
+ self.memberships_2lc = MembershipTwoLevelCache( |
+ cache_manager, self, self.group_dag) |
+ |
+ ### Group creation |
+ |
+ def CreateGroup(self, cnxn, services, group_name, who_can_view_members, |
+ ext_group_type=None, friend_projects=None): |
+ """Create a new user group. |
+ |
+ Args: |
+ cnxn: connection to SQL database. |
+ services: connections to backend services. |
+ group_name: string email address of the group to create. |
+ who_can_view_members: 'owners', 'members', or 'anyone'. |
+ ext_group_type: The type of external group to import. |
+ friend_projects: The project ids declared as group friends to view its |
+ members. |
+ |
+ Returns: |
+ int group_id of the new group. |
+ """ |
+ friend_projects = friend_projects or [] |
+ assert who_can_view_members in ('owners', 'members', 'anyone') |
+ if ext_group_type: |
+ ext_group_type = str(ext_group_type).lower() |
+ assert ext_group_type in ( |
+ 'chrome_infra_auth', 'mdb', 'baggins'), ( |
+ ext_group_type) |
+ assert who_can_view_members == 'owners' |
+ group_id = services.user.LookupUserID( |
+ cnxn, group_name.lower(), autocreate=True, allowgroups=True) |
+ group_settings = usergroup_pb2.MakeSettings( |
+ who_can_view_members, ext_group_type, 0, friend_projects) |
+ self.UpdateSettings(cnxn, group_id, group_settings) |
+ return group_id |
+ |
+ def DeleteGroups(self, cnxn, group_ids): |
+ """Delete groups' members and settings. It will NOT delete user entries. |
+ |
+ Args: |
+ cnxn: connection to SQL database. |
+ group_ids: list of group ids to delete. |
+ """ |
+ member_ids_dict, owner_ids_dict = self.LookupMembers(cnxn, group_ids) |
+ citizens_id_dict = collections.defaultdict(list) |
+ for g_id, user_ids in member_ids_dict.iteritems(): |
+ citizens_id_dict[g_id].extend(user_ids) |
+ for g_id, user_ids in owner_ids_dict.iteritems(): |
+ citizens_id_dict[g_id].extend(user_ids) |
+ for g_id, citizen_ids in citizens_id_dict.iteritems(): |
+ logging.info('Deleting group %d', g_id) |
+ # Remove group members, friend projects and settings |
+ self.RemoveMembers(cnxn, g_id, citizen_ids) |
+ self.usergroupprojects_tbl.Delete(cnxn, group_id=g_id) |
+ self.usergroupsettings_tbl.Delete(cnxn, group_id=g_id) |
+ |
+ def DetermineWhichUserIDsAreGroups(self, cnxn, user_ids): |
+ """From a list of user IDs, identify potential user groups. |
+ |
+ Args: |
+ cnxn: connection to SQL database. |
+ user_ids: list of user IDs to examine. |
+ |
+ Returns: |
+ A list with a subset of the given user IDs that are user groups |
+ rather than individual users. |
+ """ |
+ # It is a group if there is any entry in the UserGroupSettings table. |
+ group_id_rows = self.usergroupsettings_tbl.Select( |
+ cnxn, cols=['group_id'], group_id=user_ids) |
+ group_ids = [row[0] for row in group_id_rows] |
+ return group_ids |
+ |
+ ### User memberships in groups |
+ |
+ def LookupAllMemberships(self, cnxn, user_ids, use_cache=True): |
+ """Lookup all the group memberships of a list of users. |
+ |
+ Args: |
+ cnxn: connection to SQL database. |
+ user_ids: list of int user IDs to get memberships for. |
+ use_cache: set to False to ignore cached values. |
+ |
+ Returns: |
+ A dict {user_id: {group_id}} for the given user_ids. |
+ """ |
+ result_dict, missed_ids = self.memberships_2lc.GetAll( |
+ cnxn, user_ids, use_cache=use_cache) |
+ assert not missed_ids |
+ return result_dict |
+ |
+ def LookupMemberships(self, cnxn, user_id): |
+ """Return a set of group_ids that this user is a member of.""" |
+ membership_dict = self.LookupAllMemberships(cnxn, [user_id]) |
+ return membership_dict[user_id] |
+ |
+ ### Group member addition, removal, and retrieval |
+ |
+ def RemoveMembers(self, cnxn, group_id, old_member_ids): |
+ """Remove the given members/owners from the user group.""" |
+ self.usergroup_tbl.Delete( |
+ cnxn, group_id=group_id, user_id=old_member_ids) |
+ |
+ all_affected = self._GetAllMembersInList(cnxn, old_member_ids) |
+ |
+ self.group_dag.MarkObsolete() |
+ self.memberships_2lc.InvalidateAllKeys(cnxn, all_affected) |
+ |
+ def UpdateMembers(self, cnxn, group_id, member_ids, new_role): |
+ """Update role for given members/owners to the user group.""" |
+ # Circle detection |
+ for mid in member_ids: |
+ if self.group_dag.IsChild(cnxn, group_id, mid): |
+ raise CircularGroupException( |
+ '%s is already an ancestor of group %s.' % (mid, group_id)) |
+ |
+ self.usergroup_tbl.Delete( |
+ cnxn, group_id=group_id, user_id=member_ids) |
+ rows = [(member_id, group_id, new_role) for member_id in member_ids] |
+ self.usergroup_tbl.InsertRows( |
+ cnxn, ['user_id', 'group_id', 'role'], rows) |
+ |
+ all_affected = self._GetAllMembersInList(cnxn, member_ids) |
+ |
+ self.group_dag.MarkObsolete() |
+ self.memberships_2lc.InvalidateAllKeys(cnxn, all_affected) |
+ |
+ def _GetAllMembersInList(self, cnxn, group_ids): |
+ """Get all direct/indirect members/owners in a list.""" |
+ children_member_ids, children_owner_ids = self.LookupAllMembers( |
+ cnxn, group_ids) |
+ all_members_owners = set() |
+ all_members_owners.update(group_ids) |
+ for users in children_member_ids.itervalues(): |
+ all_members_owners.update(users) |
+ for users in children_owner_ids.itervalues(): |
+ all_members_owners.update(users) |
+ return list(all_members_owners) |
+ |
+ def LookupAllMembers(self, cnxn, group_ids): |
+ """Retrieve user IDs of members/owners of any of the given groups |
+ transitively.""" |
+ direct_member_rows = self.usergroup_tbl.Select( |
+ cnxn, cols=['user_id', 'group_id', 'role'], distinct=True, |
+ group_id=group_ids) |
+ member_ids_dict = {} |
+ owner_ids_dict = {} |
+ for gid in group_ids: |
+ all_descendants = self.group_dag.GetAllDescendants(cnxn, gid, True) |
+ indirect_member_rows = self.usergroup_tbl.Select( |
+ cnxn, cols=['user_id'], distinct=True, |
+ group_id=all_descendants) |
+ |
+ # Owners must have direct membership. All indirect users are members. |
+ owner_ids_dict[gid] = [m[0] for m in direct_member_rows |
+ if m[1] == gid and m[2] == 'owner'] |
+ member_ids_list = [r[0] for r in indirect_member_rows] |
+ member_ids_list.extend([m[0] for m in direct_member_rows |
+ if m[1] == gid and m[2] == 'member']) |
+ member_ids_dict[gid] = list(set(member_ids_list)) |
+ return member_ids_dict, owner_ids_dict |
+ |
+ def LookupMembers(self, cnxn, group_ids): |
+ """"Retrieve user IDs of direct members/owners of any of the given groups. |
+ |
+ Args: |
+ cnxn: connection to SQL database. |
+ group_ids: list of int user IDs for all user groups to be examined. |
+ |
+ Returns: |
+ A dict of member IDs, and a dict of owner IDs keyed by group id. |
+ """ |
+ member_rows = self.usergroup_tbl.Select( |
+ cnxn, cols=['user_id', 'group_id', 'role'], distinct=True, |
+ group_id=group_ids) |
+ member_ids_dict = {} |
+ owner_ids_dict = {} |
+ for gid in group_ids: |
+ member_ids_dict[gid] = [row[0] for row in member_rows |
+ if row[1] == gid and row[2] == 'member'] |
+ owner_ids_dict[gid] = [row[0] for row in member_rows |
+ if row[1] == gid and row[2] == 'owner'] |
+ return member_ids_dict, owner_ids_dict |
+ |
+ def ExpandAnyUserGroups(self, cnxn, user_ids): |
+ """Transitively expand any user groups and return member user IDs. |
+ |
+ Args: |
+ cnxn: connection to SQL database. |
+ user_ids: list of user IDs to check. |
+ |
+ Returns: |
+ A pair (individual_user_ids, transitive_ids). individual_user_ids |
+ is a list of user IDs that were in the given user_ids list and |
+ that identify individual members. transitive_ids is a list of |
+ user IDs of the members of any user group in the given list of |
+ user_ids and the individual members of any nested groups. |
+ """ |
+ group_ids = self.DetermineWhichUserIDsAreGroups(cnxn, user_ids) |
+ direct_ids = [uid for uid in user_ids if uid not in group_ids] |
+ member_ids_dict, owner_ids_dict = self.LookupAllMembers(cnxn, group_ids) |
+ indirect_ids = set() |
+ for gid in group_ids: |
+ indirect_ids.update(member_ids_dict[gid]) |
+ indirect_ids.update(owner_ids_dict[gid]) |
+ |
+ # Note: we return direct and indirect member IDs separately so that |
+ # the email notification footer can give more a specific reason for |
+ # why the user got an email. E.g., "You were Cc'd" vs. "You are a |
+ # member of a user group that was Cc'd". |
+ return direct_ids, list(indirect_ids) |
+ |
+ def LookupVisibleMembers( |
+ self, cnxn, group_id_list, perms, effective_ids, services): |
+ """"Retrieve the list of user group direct member/owner IDs that the user |
+ may see. |
+ |
+ Args: |
+ cnxn: connection to SQL database. |
+ group_id_list: list of int user IDs for all user groups to be examined. |
+ perms: optional PermissionSet for the user viewing this page. |
+ effective_ids: set of int user IDs for that user and all |
+ his/her group memberships. |
+ services: backend services. |
+ |
+ Returns: |
+ A list of all the member IDs from any group that the user is allowed |
+ to view. |
+ """ |
+ settings_dict = self.GetAllGroupSettings(cnxn, group_id_list) |
+ group_ids = settings_dict.keys() |
+ (owned_project_ids, membered_project_ids, |
+ contrib_project_ids) = services.project.GetUserRolesInAllProjects( |
+ cnxn, effective_ids) |
+ project_ids = owned_project_ids.union( |
+ membered_project_ids).union(contrib_project_ids) |
+ # We need to fetch all members/owners to determine whether the requester |
+ # has permission to view. |
+ direct_member_ids_dict, direct_owner_ids_dict = self.LookupMembers( |
+ cnxn, group_ids) |
+ all_member_ids_dict, all_owner_ids_dict = self.LookupAllMembers( |
+ cnxn, group_ids) |
+ visible_member_ids = {} |
+ visible_owner_ids = {} |
+ for gid in group_ids: |
+ member_ids = all_member_ids_dict[gid] |
+ owner_ids = all_owner_ids_dict[gid] |
+ |
+ if permissions.CanViewGroup(perms, effective_ids, settings_dict[gid], |
+ member_ids, owner_ids, project_ids): |
+ visible_member_ids[gid] = direct_member_ids_dict[gid] |
+ visible_owner_ids[gid] = direct_owner_ids_dict[gid] |
+ |
+ return visible_member_ids, visible_owner_ids |
+ |
+ ### Group settings |
+ |
+ def GetAllUserGroupsInfo(self, cnxn): |
+ """Fetch (addr, member_count, usergroup_settings) for all user groups.""" |
+ group_rows = self.usergroupsettings_tbl.Select( |
+ cnxn, cols=['group_id', 'email', 'who_can_view_members', |
+ 'external_group_type', 'last_sync_time'], |
+ left_joins=[('User ON UserGroupSettings.group_id = User.user_id', [])]) |
+ count_rows = self.usergroup_tbl.Select( |
+ cnxn, cols=['group_id', 'COUNT(*)'], |
+ group_by=['group_id']) |
+ count_dict = dict(count_rows) |
+ group_ids = [g[0] for g in group_rows] |
+ friends_dict = self.GetAllGroupFriendProjects(cnxn, group_ids) |
+ |
+ user_group_info_tuples = [ |
+ (email, count_dict.get(group_id, 0), |
+ usergroup_pb2.MakeSettings(visiblity, group_type, last_sync_time, |
+ friends_dict.get(group_id, [])), |
+ group_id) |
+ for (group_id, email, visiblity, group_type, last_sync_time) |
+ in group_rows] |
+ return user_group_info_tuples |
+ |
+ def GetAllGroupSettings(self, cnxn, group_ids): |
+ """Fetch {group_id: group_settings} for the specified groups.""" |
+ # TODO(jrobbins): add settings to control who can join, etc. |
+ rows = self.usergroupsettings_tbl.Select( |
+ cnxn, cols=USERGROUPSETTINGS_COLS, group_id=group_ids) |
+ friends_dict = self.GetAllGroupFriendProjects(cnxn, group_ids) |
+ settings_dict = { |
+ group_id: usergroup_pb2.MakeSettings( |
+ vis, group_type, last_sync_time, friends_dict.get(group_id, [])) |
+ for group_id, vis, group_type, last_sync_time in rows} |
+ return settings_dict |
+ |
+ def GetGroupSettings(self, cnxn, group_id): |
+ """Retrieve group settings for the specified user group. |
+ |
+ Args: |
+ cnxn: connection to SQL database. |
+ group_id: int user ID of the user group. |
+ |
+ Returns: |
+ A UserGroupSettings object, or None if no such group exists. |
+ """ |
+ return self.GetAllGroupSettings(cnxn, [group_id]).get(group_id) |
+ |
+ def UpdateSettings(self, cnxn, group_id, group_settings): |
+ """Update the visiblity settings of the specified group.""" |
+ who_can_view_members = str(group_settings.who_can_view_members).lower() |
+ ext_group_type = group_settings.ext_group_type |
+ assert who_can_view_members in ('owners', 'members', 'anyone') |
+ if ext_group_type: |
+ ext_group_type = str(group_settings.ext_group_type).lower() |
+ assert ext_group_type in ( |
+ 'chrome_infra_auth', 'mdb', 'baggins'), ( |
+ ext_group_type) |
+ assert who_can_view_members == 'owners' |
+ self.usergroupsettings_tbl.InsertRow( |
+ cnxn, group_id=group_id, who_can_view_members=who_can_view_members, |
+ external_group_type=ext_group_type, |
+ last_sync_time=group_settings.last_sync_time, |
+ replace=True) |
+ self.usergroupprojects_tbl.Delete( |
+ cnxn, group_id=group_id) |
+ if group_settings.friend_projects: |
+ rows = [(group_id, p_id) for p_id in group_settings.friend_projects] |
+ self.usergroupprojects_tbl.InsertRows( |
+ cnxn, ['group_id', 'project_id'], rows) |
+ |
+ def GetAllGroupFriendProjects(self, cnxn, group_ids): |
+ """Get {group_id: [project_ids]} for the specified user groups.""" |
+ rows = self.usergroupprojects_tbl.Select( |
+ cnxn, cols=USERGROUPPROJECTS_COLS, group_id=group_ids) |
+ friends_dict = {} |
+ for group_id, project_id in rows: |
+ friends_dict.setdefault(group_id, []).append(project_id) |
+ return friends_dict |
+ |
+ def GetGroupFriendProjects(self, cnxn, group_id): |
+ """Get a list of friend projects for the specified user group.""" |
+ return self.GetAllGroupFriendProjects(cnxn, [group_id]).get(group_id) |
+ |
+ def ValidateFriendProjects(self, cnxn, services, friend_projects): |
+ """Validate friend projects. |
+ |
+ Returns: |
+ A list of project ids if no errors, or an error message. |
+ """ |
+ project_names = filter(None, re.split('; |, | |;|,', friend_projects)) |
+ id_dict = services.project.LookupProjectIDs(cnxn, project_names) |
+ missed_projects = [] |
+ result = [] |
+ for p_name in project_names: |
+ if p_name in id_dict: |
+ result.append(id_dict[p_name]) |
+ else: |
+ missed_projects.append(p_name) |
+ error_msg = '' |
+ if missed_projects: |
+ error_msg = 'Project(s) %s do not exist' % ', '.join(missed_projects) |
+ return None, error_msg |
+ else: |
+ return result, None |
+ |
+ # TODO(jrobbins): re-implement FindUntrustedGroups() |
+ |
+ |
+class UserGroupDAG(object): |
+ """A directed-acyclic graph of potentially nested user groups.""" |
+ |
+ def __init__(self, usergroup_service): |
+ self.usergroup_service = usergroup_service |
+ self.user_group_parents = collections.defaultdict(list) |
+ self.user_group_children = collections.defaultdict(list) |
+ self.initialized = False |
+ |
+ def Build(self, cnxn, circle_detection=False): |
+ if not self.initialized: |
+ self.user_group_parents.clear() |
+ self.user_group_children.clear() |
+ group_ids = self.usergroup_service.usergroupsettings_tbl.Select( |
+ cnxn, cols=['group_id']) |
+ usergroup_rows = self.usergroup_service.usergroup_tbl.Select( |
+ cnxn, cols=['user_id', 'group_id'], distinct=True, |
+ user_id=[r[0] for r in group_ids]) |
+ for user_id, group_id in usergroup_rows: |
+ self.user_group_parents[user_id].append(group_id) |
+ self.user_group_children[group_id].append(user_id) |
+ self.initialized = True |
+ |
+ if circle_detection: |
+ for child_id, parent_ids in self.user_group_parents.iteritems(): |
+ for parent_id in parent_ids: |
+ if self.IsChild(cnxn, parent_id, child_id): |
+ logging.error( |
+ 'Circle exists between group %d and %d.', child_id, parent_id) |
+ |
+ def GetAllAncestors(self, cnxn, group_id, circle_detection=False): |
+ """Return a list of distinct ancestor group IDs for the given group.""" |
+ self.Build(cnxn, circle_detection) |
+ result = set() |
+ child_ids = [group_id] |
+ while child_ids: |
+ parent_ids = set() |
+ for c_id in child_ids: |
+ group_ids = self.user_group_parents[c_id] |
+ parent_ids.update(g_id for g_id in group_ids if g_id not in result) |
+ result.update(parent_ids) |
+ child_ids = list(parent_ids) |
+ return list(result) |
+ |
+ def GetAllDescendants(self, cnxn, group_id, circle_detection=False): |
+ """Return a list of distinct descendant group IDs for the given group.""" |
+ self.Build(cnxn, circle_detection) |
+ result = set() |
+ parent_ids = [group_id] |
+ while parent_ids: |
+ child_ids = set() |
+ for p_id in parent_ids: |
+ group_ids = self.user_group_children[p_id] |
+ child_ids.update(g_id for g_id in group_ids if g_id not in result) |
+ result.update(child_ids) |
+ parent_ids = list(child_ids) |
+ return list(result) |
+ |
+ def IsChild(self, cnxn, child_id, parent_id): |
+ """Returns True if child_id is a direct/indirect child of parent_id.""" |
+ all_descendants = self.GetAllDescendants(cnxn, parent_id) |
+ return child_id in all_descendants |
+ |
+ def MarkObsolete(self): |
+ """Mark the DAG as uninitialized so it'll be re-built.""" |
+ self.initialized = False |
+ |
+ def __repr__(self): |
+ result = {} |
+ result['parents'] = self.user_group_parents |
+ result['children'] = self.user_group_children |
+ return str(result) |
+ |
+ |
+class Error(Exception): |
+ """Base class for errors from this module.""" |
+ pass |
+ |
+ |
+class CircularGroupException(Error): |
+ """Circular nested group exception.""" |
+ pass |
+ |
+ |
+class GroupExistsException(Error): |
+ """Group already exists exception.""" |
+ pass |