Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/services/usergroup_svc.py b/services/usergroup_svc.py
new file mode 100644
index 0000000..72797fc
--- /dev/null
+++ b/services/usergroup_svc.py
@@ -0,0 +1,616 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed 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.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import logging
+import re
+
+from framework import exceptions
+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',
+                          'notify_members', 'notify_group']
+USERGROUPPROJECTS_COLS = ['group_id', 'project_id']
+
+GROUP_TYPE_ENUM = (
+    'chrome_infra_auth', 'mdb', 'baggins', 'computed')
+
+
+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()
+    self.group_dag.MarkObsolete()
+    logging.info('Rebuild group dag on RAM and memcache miss')
+    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)
+    # Like a dictionary {group_email: [group_id]}
+    self.group_id_cache = caches.ValueCentricRamCache(
+        cache_manager, 'usergroup')
+
+  ### 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 GROUP_TYPE_ENUM, 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)
+    self.group_id_cache.InvalidateAll(cnxn)
+    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.items():
+      citizens_id_dict[g_id].extend(user_ids)
+    for g_id, user_ids in owner_ids_dict.items():
+      citizens_id_dict[g_id].extend(user_ids)
+    for g_id, citizen_ids in citizens_id_dict.items():
+      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)
+    self.group_id_cache.InvalidateAll(cnxn)
+
+  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 LookupComputedMemberships(self, cnxn, domain, use_cache=True):
+    """Look up the computed group memberships of a list of users.
+
+    Args:
+      cnxn: connection to SQL database.
+      domain: string with domain part of user's email address.
+      use_cache: set to False to ignore cached values.
+
+    Returns:
+      A list [group_id] of computed user groups that match the user.
+      For now, the length of this list will always be zero or one.
+    """
+    group_email = 'everyone@%s' % domain
+    group_id = self.LookupUserGroupID(cnxn, group_email, use_cache=use_cache)
+    if group_id:
+      return [group_id]
+
+    return []
+
+  def LookupUserGroupID(self, cnxn, group_email, use_cache=True):
+    """Lookup the group ID for the given user group email address.
+
+    Args:
+      cnxn: connection to SQL database.
+      group_email: string that identies the user group.
+      use_cache: set to False to ignore cached values.
+
+    Returns:
+      Int group_id if found, otherwise None.
+    """
+    if use_cache and self.group_id_cache.HasItem(group_email):
+      return self.group_id_cache.GetItem(group_email)
+
+    rows = self.usergroupsettings_tbl.Select(
+        cnxn, cols=['email', 'group_id'],
+        left_joins=[('User ON UserGroupSettings.group_id = User.user_id', [])],
+        email=group_email,
+        where=[('group_id IS NOT NULL', [])])
+    retrieved_dict = dict(rows)
+    # Cache a "not found" value for emails that are not user groups.
+    if group_email not in retrieved_dict:
+      retrieved_dict[group_email] = None
+    self.group_id_cache.CacheAll(retrieved_dict)
+
+    return retrieved_dict.get(group_email)
+
+  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 exceptions.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.values():
+      all_members_owners.update(users)
+    for users in children_owner_ids.values():
+      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."""
+    member_ids_dict = {}
+    owner_ids_dict = {}
+    if not group_ids:
+      return member_ids_dict, owner_ids_dict
+    direct_member_rows = self.usergroup_tbl.Select(
+        cnxn, cols=['user_id', 'group_id', 'role'], distinct=True,
+        group_id=group_ids)
+    for gid in group_ids:
+      all_descendants = self.group_dag.GetAllDescendants(cnxn, gid, True)
+      indirect_member_rows = []
+      if all_descendants:
+        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_ids_dict = {}
+    owner_ids_dict = {}
+    if not group_ids:
+      return member_ids_dict, owner_ids_dict
+    member_rows = self.usergroup_tbl.Select(
+        cnxn, cols=['user_id', 'group_id', 'role'], distinct=True,
+        group_id=group_ids)
+    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 ExpandAnyGroupEmailRecipients(self, cnxn, user_ids):
+    """Expand the list with members that are part of a group configured
+       to have notifications sent directly to members. Remove any groups
+       not configured to have notifications sent directly to the group.
+
+    Args:
+      cnxn: connection to SQL database.
+      user_ids: list of user IDs to check.
+
+    Returns:
+      A paire (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 or a group that has
+          settings.notify_group set to True. transitive_ids is a list of
+          user IDs of members of any user group in user_ids with
+          settings.notify_members set to True.
+    """
+    group_ids = self.DetermineWhichUserIDsAreGroups(cnxn, user_ids)
+    group_settings_dict = self.GetAllGroupSettings(cnxn, group_ids)
+    member_ids_dict, owner_ids_dict = self.LookupAllMembers(cnxn, group_ids)
+    indirect_ids = set()
+    direct_ids = {uid for uid in user_ids if uid not in group_ids}
+    for gid, settings in group_settings_dict.items():
+      if settings.notify_members:
+        indirect_ids.update(member_ids_dict.get(gid, set()))
+        indirect_ids.update(owner_ids_dict.get(gid, set()))
+      if settings.notify_group:
+        direct_ids.add(gid)
+
+    return list(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
+          their 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 = list(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.CanViewGroupMembers(
+          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=['email'] + USERGROUPSETTINGS_COLS,
+        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[1] 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, []),
+                                    bool(notify_members), bool(notify_group)),
+         group_id)
+        for (email, group_id, visiblity, group_type, last_sync_time,
+             notify_members, notify_group) 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, []),
+            notify_members=bool(notify_members),
+            notify_group=bool(notify_group))
+        for (group_id, vis, group_type, last_sync_time,
+             notify_members, notify_group) 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 GROUP_TYPE_ENUM, 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,
+        notify_members=group_settings.notify_members,
+        notify_group=group_settings.notify_group,
+        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 = list(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()
+
+  def ExpungeUsersInGroups(self, cnxn, ids):
+    """Wipes the given user from the groups system.
+    The given user_ids may to members or groups, or groups themselves.
+    The groups and all their members will be deleted. The users will be
+    wiped from the groups they belong to.
+
+    It will NOT delete user entries. This method will not commit the
+    operations. This method will not make any changes to in-memory data.
+    """
+    # Delete any groups
+    self.usergroupprojects_tbl.Delete(cnxn, group_id=ids, commit=False)
+    self.usergroupsettings_tbl.Delete(cnxn, group_id=ids, commit=False)
+    self.usergroup_tbl.Delete(cnxn, group_id=ids, commit=False)
+
+    # Delete any group members
+    self.usergroup_tbl.Delete(cnxn, user_id=ids, commit=False)
+
+
+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.items():
+        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)