blob: 5959626ee85a194a2379baf1dce7bd75df24936b [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001# Copyright 2016 The Chromium Authors
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
Copybara854996b2021-09-07 19:36:02 +00004
5"""Persistence class for user groups.
6
7User groups are represented in the database by:
8- A row in the Users table giving an email address and user ID.
9 (A "group ID" is the user_id of the group in the User table.)
10- A row in the UserGroupSettings table giving user group settings.
11
12Membership of a user X in user group Y is represented as:
13- A row in the UserGroup table with user_id=X and group_id=Y.
14"""
15from __future__ import print_function
16from __future__ import division
17from __future__ import absolute_import
18
19import collections
20import logging
21import re
22
23from framework import exceptions
24from framework import permissions
25from framework import sql
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010026from mrproto import usergroup_pb2
Copybara854996b2021-09-07 19:36:02 +000027from services import caches
28
29
30USERGROUP_TABLE_NAME = 'UserGroup'
31USERGROUPSETTINGS_TABLE_NAME = 'UserGroupSettings'
32USERGROUPPROJECTS_TABLE_NAME = 'Group2Project'
33
34USERGROUP_COLS = ['user_id', 'group_id', 'role']
35USERGROUPSETTINGS_COLS = ['group_id', 'who_can_view_members',
36 'external_group_type', 'last_sync_time',
37 'notify_members', 'notify_group']
38USERGROUPPROJECTS_COLS = ['group_id', 'project_id']
39
40GROUP_TYPE_ENUM = (
41 'chrome_infra_auth', 'mdb', 'baggins', 'computed')
42
43
44class MembershipTwoLevelCache(caches.AbstractTwoLevelCache):
45 """Class to manage RAM and memcache for each user's memberships."""
46
47 def __init__(self, cache_manager, usergroup_service, group_dag):
48 super(MembershipTwoLevelCache, self).__init__(
49 cache_manager, 'user', 'memberships:', None)
50 self.usergroup_service = usergroup_service
51 self.group_dag = group_dag
52
53 def _DeserializeMemberships(self, memberships_rows):
54 """Reserialize the DB results into a {user_id: {group_id}}."""
55 result_dict = collections.defaultdict(set)
56 for user_id, group_id in memberships_rows:
57 result_dict[user_id].add(group_id)
58
59 return result_dict
60
61 def FetchItems(self, cnxn, keys):
62 """On RAM and memcache miss, hit the database to get memberships."""
63 direct_memberships_rows = self.usergroup_service.usergroup_tbl.Select(
64 cnxn, cols=['user_id', 'group_id'], distinct=True,
65 user_id=keys)
66 memberships_set = set()
67 self.group_dag.MarkObsolete()
68 logging.info('Rebuild group dag on RAM and memcache miss')
69 for c_id, p_id in direct_memberships_rows:
70 all_parents = self.group_dag.GetAllAncestors(cnxn, p_id, True)
71 all_parents.append(p_id)
72 memberships_set.update([(c_id, g_id) for g_id in all_parents])
73 retrieved_dict = self._DeserializeMemberships(list(memberships_set))
74
75 # Make sure that every requested user is in the result, and gets cached.
76 retrieved_dict.update(
77 (user_id, set()) for user_id in keys
78 if user_id not in retrieved_dict)
79 return retrieved_dict
80
81
82class UserGroupService(object):
83 """The persistence layer for user group data."""
84
85 def __init__(self, cache_manager):
86 """Initialize this service so that it is ready to use.
87
88 Args:
89 cache_manager: local cache with distributed invalidation.
90 """
91 self.usergroup_tbl = sql.SQLTableManager(USERGROUP_TABLE_NAME)
92 self.usergroupsettings_tbl = sql.SQLTableManager(
93 USERGROUPSETTINGS_TABLE_NAME)
94 self.usergroupprojects_tbl = sql.SQLTableManager(
95 USERGROUPPROJECTS_TABLE_NAME)
96
97 self.group_dag = UserGroupDAG(self)
98
99 # Like a dictionary {user_id: {group_id}}
100 self.memberships_2lc = MembershipTwoLevelCache(
101 cache_manager, self, self.group_dag)
102 # Like a dictionary {group_email: [group_id]}
103 self.group_id_cache = caches.ValueCentricRamCache(
104 cache_manager, 'usergroup')
105
106 ### Group creation
107
108 def CreateGroup(self, cnxn, services, group_name, who_can_view_members,
109 ext_group_type=None, friend_projects=None):
110 """Create a new user group.
111
112 Args:
113 cnxn: connection to SQL database.
114 services: connections to backend services.
115 group_name: string email address of the group to create.
116 who_can_view_members: 'owners', 'members', or 'anyone'.
117 ext_group_type: The type of external group to import.
118 friend_projects: The project ids declared as group friends to view its
119 members.
120
121 Returns:
122 int group_id of the new group.
123 """
124 friend_projects = friend_projects or []
125 assert who_can_view_members in ('owners', 'members', 'anyone')
126 if ext_group_type:
127 ext_group_type = str(ext_group_type).lower()
128 assert ext_group_type in GROUP_TYPE_ENUM, ext_group_type
129 assert who_can_view_members == 'owners'
130 group_id = services.user.LookupUserID(
131 cnxn, group_name.lower(), autocreate=True, allowgroups=True)
132 group_settings = usergroup_pb2.MakeSettings(
133 who_can_view_members, ext_group_type, 0, friend_projects)
134 self.UpdateSettings(cnxn, group_id, group_settings)
135 self.group_id_cache.InvalidateAll(cnxn)
136 return group_id
137
138 def DeleteGroups(self, cnxn, group_ids):
139 """Delete groups' members and settings. It will NOT delete user entries.
140
141 Args:
142 cnxn: connection to SQL database.
143 group_ids: list of group ids to delete.
144 """
145 member_ids_dict, owner_ids_dict = self.LookupMembers(cnxn, group_ids)
146 citizens_id_dict = collections.defaultdict(list)
147 for g_id, user_ids in member_ids_dict.items():
148 citizens_id_dict[g_id].extend(user_ids)
149 for g_id, user_ids in owner_ids_dict.items():
150 citizens_id_dict[g_id].extend(user_ids)
151 for g_id, citizen_ids in citizens_id_dict.items():
152 logging.info('Deleting group %d', g_id)
153 # Remove group members, friend projects and settings
154 self.RemoveMembers(cnxn, g_id, citizen_ids)
155 self.usergroupprojects_tbl.Delete(cnxn, group_id=g_id)
156 self.usergroupsettings_tbl.Delete(cnxn, group_id=g_id)
157 self.group_id_cache.InvalidateAll(cnxn)
158
159 def DetermineWhichUserIDsAreGroups(self, cnxn, user_ids):
160 """From a list of user IDs, identify potential user groups.
161
162 Args:
163 cnxn: connection to SQL database.
164 user_ids: list of user IDs to examine.
165
166 Returns:
167 A list with a subset of the given user IDs that are user groups
168 rather than individual users.
169 """
170 # It is a group if there is any entry in the UserGroupSettings table.
171 group_id_rows = self.usergroupsettings_tbl.Select(
172 cnxn, cols=['group_id'], group_id=user_ids)
173 group_ids = [row[0] for row in group_id_rows]
174 return group_ids
175
176 ### User memberships in groups
177
178 def LookupComputedMemberships(self, cnxn, domain, use_cache=True):
179 """Look up the computed group memberships of a list of users.
180
181 Args:
182 cnxn: connection to SQL database.
183 domain: string with domain part of user's email address.
184 use_cache: set to False to ignore cached values.
185
186 Returns:
187 A list [group_id] of computed user groups that match the user.
188 For now, the length of this list will always be zero or one.
189 """
190 group_email = 'everyone@%s' % domain
191 group_id = self.LookupUserGroupID(cnxn, group_email, use_cache=use_cache)
192 if group_id:
193 return [group_id]
194
195 return []
196
197 def LookupUserGroupID(self, cnxn, group_email, use_cache=True):
198 """Lookup the group ID for the given user group email address.
199
200 Args:
201 cnxn: connection to SQL database.
202 group_email: string that identies the user group.
203 use_cache: set to False to ignore cached values.
204
205 Returns:
206 Int group_id if found, otherwise None.
207 """
208 if use_cache and self.group_id_cache.HasItem(group_email):
209 return self.group_id_cache.GetItem(group_email)
210
211 rows = self.usergroupsettings_tbl.Select(
212 cnxn, cols=['email', 'group_id'],
213 left_joins=[('User ON UserGroupSettings.group_id = User.user_id', [])],
214 email=group_email,
215 where=[('group_id IS NOT NULL', [])])
216 retrieved_dict = dict(rows)
217 # Cache a "not found" value for emails that are not user groups.
218 if group_email not in retrieved_dict:
219 retrieved_dict[group_email] = None
220 self.group_id_cache.CacheAll(retrieved_dict)
221
222 return retrieved_dict.get(group_email)
223
224 def LookupAllMemberships(self, cnxn, user_ids, use_cache=True):
225 """Lookup all the group memberships of a list of users.
226
227 Args:
228 cnxn: connection to SQL database.
229 user_ids: list of int user IDs to get memberships for.
230 use_cache: set to False to ignore cached values.
231
232 Returns:
233 A dict {user_id: {group_id}} for the given user_ids.
234 """
235 result_dict, missed_ids = self.memberships_2lc.GetAll(
236 cnxn, user_ids, use_cache=use_cache)
237 assert not missed_ids
238 return result_dict
239
240 def LookupMemberships(self, cnxn, user_id):
241 """Return a set of group_ids that this user is a member of."""
242 membership_dict = self.LookupAllMemberships(cnxn, [user_id])
243 return membership_dict[user_id]
244
245 ### Group member addition, removal, and retrieval
246
247 def RemoveMembers(self, cnxn, group_id, old_member_ids):
248 """Remove the given members/owners from the user group."""
249 self.usergroup_tbl.Delete(
250 cnxn, group_id=group_id, user_id=old_member_ids)
251
252 all_affected = self._GetAllMembersInList(cnxn, old_member_ids)
253
254 self.group_dag.MarkObsolete()
255 self.memberships_2lc.InvalidateAllKeys(cnxn, all_affected)
256
257 def UpdateMembers(self, cnxn, group_id, member_ids, new_role):
258 """Update role for given members/owners to the user group."""
259 # Circle detection
260 for mid in member_ids:
261 if self.group_dag.IsChild(cnxn, group_id, mid):
262 raise exceptions.CircularGroupException(
263 '%s is already an ancestor of group %s.' % (mid, group_id))
264
265 self.usergroup_tbl.Delete(
266 cnxn, group_id=group_id, user_id=member_ids)
267 rows = [(member_id, group_id, new_role) for member_id in member_ids]
268 self.usergroup_tbl.InsertRows(
269 cnxn, ['user_id', 'group_id', 'role'], rows)
270
271 all_affected = self._GetAllMembersInList(cnxn, member_ids)
272
273 self.group_dag.MarkObsolete()
274 self.memberships_2lc.InvalidateAllKeys(cnxn, all_affected)
275
276 def _GetAllMembersInList(self, cnxn, group_ids):
277 """Get all direct/indirect members/owners in a list."""
278 children_member_ids, children_owner_ids = self.LookupAllMembers(
279 cnxn, group_ids)
280 all_members_owners = set()
281 all_members_owners.update(group_ids)
282 for users in children_member_ids.values():
283 all_members_owners.update(users)
284 for users in children_owner_ids.values():
285 all_members_owners.update(users)
286 return list(all_members_owners)
287
288 def LookupAllMembers(self, cnxn, group_ids):
289 """Retrieve user IDs of members/owners of any of the given groups
290 transitively."""
291 member_ids_dict = {}
292 owner_ids_dict = {}
293 if not group_ids:
294 return member_ids_dict, owner_ids_dict
295 direct_member_rows = self.usergroup_tbl.Select(
296 cnxn, cols=['user_id', 'group_id', 'role'], distinct=True,
297 group_id=group_ids)
298 for gid in group_ids:
299 all_descendants = self.group_dag.GetAllDescendants(cnxn, gid, True)
300 indirect_member_rows = []
301 if all_descendants:
302 indirect_member_rows = self.usergroup_tbl.Select(
303 cnxn, cols=['user_id'], distinct=True,
304 group_id=all_descendants)
305
306 # Owners must have direct membership. All indirect users are members.
307 owner_ids_dict[gid] = [m[0] for m in direct_member_rows
308 if m[1] == gid and m[2] == 'owner']
309 member_ids_list = [r[0] for r in indirect_member_rows]
310 member_ids_list.extend([m[0] for m in direct_member_rows
311 if m[1] == gid and m[2] == 'member'])
312 member_ids_dict[gid] = list(set(member_ids_list))
313 return member_ids_dict, owner_ids_dict
314
315 def LookupMembers(self, cnxn, group_ids):
316 """"Retrieve user IDs of direct members/owners of any of the given groups.
317
318 Args:
319 cnxn: connection to SQL database.
320 group_ids: list of int user IDs for all user groups to be examined.
321
322 Returns:
323 A dict of member IDs, and a dict of owner IDs keyed by group id.
324 """
325 member_ids_dict = {}
326 owner_ids_dict = {}
327 if not group_ids:
328 return member_ids_dict, owner_ids_dict
329 member_rows = self.usergroup_tbl.Select(
330 cnxn, cols=['user_id', 'group_id', 'role'], distinct=True,
331 group_id=group_ids)
332 for gid in group_ids:
333 member_ids_dict[gid] = [row[0] for row in member_rows
334 if row[1] == gid and row[2] == 'member']
335 owner_ids_dict[gid] = [row[0] for row in member_rows
336 if row[1] == gid and row[2] == 'owner']
337 return member_ids_dict, owner_ids_dict
338
339 def ExpandAnyGroupEmailRecipients(self, cnxn, user_ids):
340 """Expand the list with members that are part of a group configured
341 to have notifications sent directly to members. Remove any groups
342 not configured to have notifications sent directly to the group.
343
344 Args:
345 cnxn: connection to SQL database.
346 user_ids: list of user IDs to check.
347
348 Returns:
349 A paire (individual user_ids, transitive_ids). individual_user_ids
350 is a list of user IDs that were in the given user_ids list and
351 that identify individual members or a group that has
352 settings.notify_group set to True. transitive_ids is a list of
353 user IDs of members of any user group in user_ids with
354 settings.notify_members set to True.
355 """
356 group_ids = self.DetermineWhichUserIDsAreGroups(cnxn, user_ids)
357 group_settings_dict = self.GetAllGroupSettings(cnxn, group_ids)
358 member_ids_dict, owner_ids_dict = self.LookupAllMembers(cnxn, group_ids)
359 indirect_ids = set()
360 direct_ids = {uid for uid in user_ids if uid not in group_ids}
361 for gid, settings in group_settings_dict.items():
362 if settings.notify_members:
363 indirect_ids.update(member_ids_dict.get(gid, set()))
364 indirect_ids.update(owner_ids_dict.get(gid, set()))
365 if settings.notify_group:
366 direct_ids.add(gid)
367
368 return list(direct_ids), list(indirect_ids)
369
370 def LookupVisibleMembers(
371 self, cnxn, group_id_list, perms, effective_ids, services):
372 """"Retrieve the list of user group direct member/owner IDs that the user
373 may see.
374
375 Args:
376 cnxn: connection to SQL database.
377 group_id_list: list of int user IDs for all user groups to be examined.
378 perms: optional PermissionSet for the user viewing this page.
379 effective_ids: set of int user IDs for that user and all
380 their group memberships.
381 services: backend services.
382
383 Returns:
384 A list of all the member IDs from any group that the user is allowed
385 to view.
386 """
387 settings_dict = self.GetAllGroupSettings(cnxn, group_id_list)
388 group_ids = list(settings_dict.keys())
389 (owned_project_ids, membered_project_ids,
390 contrib_project_ids) = services.project.GetUserRolesInAllProjects(
391 cnxn, effective_ids)
392 project_ids = owned_project_ids.union(
393 membered_project_ids).union(contrib_project_ids)
394 # We need to fetch all members/owners to determine whether the requester
395 # has permission to view.
396 direct_member_ids_dict, direct_owner_ids_dict = self.LookupMembers(
397 cnxn, group_ids)
398 all_member_ids_dict, all_owner_ids_dict = self.LookupAllMembers(
399 cnxn, group_ids)
400 visible_member_ids = {}
401 visible_owner_ids = {}
402 for gid in group_ids:
403 member_ids = all_member_ids_dict[gid]
404 owner_ids = all_owner_ids_dict[gid]
405
406 if permissions.CanViewGroupMembers(
407 perms, effective_ids, settings_dict[gid], member_ids, owner_ids,
408 project_ids):
409 visible_member_ids[gid] = direct_member_ids_dict[gid]
410 visible_owner_ids[gid] = direct_owner_ids_dict[gid]
411
412 return visible_member_ids, visible_owner_ids
413
414 ### Group settings
415
416 def GetAllUserGroupsInfo(self, cnxn):
417 """Fetch (addr, member_count, usergroup_settings) for all user groups."""
418 group_rows = self.usergroupsettings_tbl.Select(
419 cnxn, cols=['email'] + USERGROUPSETTINGS_COLS,
420 left_joins=[('User ON UserGroupSettings.group_id = User.user_id', [])])
421 count_rows = self.usergroup_tbl.Select(
422 cnxn, cols=['group_id', 'COUNT(*)'],
423 group_by=['group_id'])
424 count_dict = dict(count_rows)
425
426 group_ids = [g[1] for g in group_rows]
427 friends_dict = self.GetAllGroupFriendProjects(cnxn, group_ids)
428
429 user_group_info_tuples = [
430 (email, count_dict.get(group_id, 0),
431 usergroup_pb2.MakeSettings(visiblity, group_type, last_sync_time,
432 friends_dict.get(group_id, []),
433 bool(notify_members), bool(notify_group)),
434 group_id)
435 for (email, group_id, visiblity, group_type, last_sync_time,
436 notify_members, notify_group) in group_rows]
437 return user_group_info_tuples
438
439 def GetAllGroupSettings(self, cnxn, group_ids):
440 """Fetch {group_id: group_settings} for the specified groups."""
441 # TODO(jrobbins): add settings to control who can join, etc.
442 rows = self.usergroupsettings_tbl.Select(
443 cnxn, cols=USERGROUPSETTINGS_COLS, group_id=group_ids)
444 friends_dict = self.GetAllGroupFriendProjects(cnxn, group_ids)
445 settings_dict = {
446 group_id: usergroup_pb2.MakeSettings(
447 vis, group_type, last_sync_time, friends_dict.get(group_id, []),
448 notify_members=bool(notify_members),
449 notify_group=bool(notify_group))
450 for (group_id, vis, group_type, last_sync_time,
451 notify_members, notify_group) in rows}
452 return settings_dict
453
454 def GetGroupSettings(self, cnxn, group_id):
455 """Retrieve group settings for the specified user group.
456
457 Args:
458 cnxn: connection to SQL database.
459 group_id: int user ID of the user group.
460
461 Returns:
462 A UserGroupSettings object, or None if no such group exists.
463 """
464 return self.GetAllGroupSettings(cnxn, [group_id]).get(group_id)
465
466 def UpdateSettings(self, cnxn, group_id, group_settings):
467 """Update the visiblity settings of the specified group."""
468 who_can_view_members = str(group_settings.who_can_view_members).lower()
469 ext_group_type = group_settings.ext_group_type
470 assert who_can_view_members in ('owners', 'members', 'anyone')
471 if ext_group_type:
472 ext_group_type = str(group_settings.ext_group_type).lower()
473 assert ext_group_type in GROUP_TYPE_ENUM, ext_group_type
474 assert who_can_view_members == 'owners'
475 self.usergroupsettings_tbl.InsertRow(
476 cnxn, group_id=group_id, who_can_view_members=who_can_view_members,
477 external_group_type=ext_group_type,
478 last_sync_time=group_settings.last_sync_time,
479 notify_members=group_settings.notify_members,
480 notify_group=group_settings.notify_group,
481 replace=True)
482 self.usergroupprojects_tbl.Delete(
483 cnxn, group_id=group_id)
484 if group_settings.friend_projects:
485 rows = [(group_id, p_id) for p_id in group_settings.friend_projects]
486 self.usergroupprojects_tbl.InsertRows(
487 cnxn, ['group_id', 'project_id'], rows)
488
489 def GetAllGroupFriendProjects(self, cnxn, group_ids):
490 """Get {group_id: [project_ids]} for the specified user groups."""
491 rows = self.usergroupprojects_tbl.Select(
492 cnxn, cols=USERGROUPPROJECTS_COLS, group_id=group_ids)
493 friends_dict = {}
494 for group_id, project_id in rows:
495 friends_dict.setdefault(group_id, []).append(project_id)
496 return friends_dict
497
498 def GetGroupFriendProjects(self, cnxn, group_id):
499 """Get a list of friend projects for the specified user group."""
500 return self.GetAllGroupFriendProjects(cnxn, [group_id]).get(group_id)
501
502 def ValidateFriendProjects(self, cnxn, services, friend_projects):
503 """Validate friend projects.
504
505 Returns:
506 A list of project ids if no errors, or an error message.
507 """
508 project_names = list(filter(None, re.split('; |, | |;|,', friend_projects)))
509 id_dict = services.project.LookupProjectIDs(cnxn, project_names)
510 missed_projects = []
511 result = []
512 for p_name in project_names:
513 if p_name in id_dict:
514 result.append(id_dict[p_name])
515 else:
516 missed_projects.append(p_name)
517 error_msg = ''
518 if missed_projects:
519 error_msg = 'Project(s) %s do not exist' % ', '.join(missed_projects)
520 return None, error_msg
521 else:
522 return result, None
523
524 # TODO(jrobbins): re-implement FindUntrustedGroups()
525
526 def ExpungeUsersInGroups(self, cnxn, ids):
527 """Wipes the given user from the groups system.
528 The given user_ids may to members or groups, or groups themselves.
529 The groups and all their members will be deleted. The users will be
530 wiped from the groups they belong to.
531
532 It will NOT delete user entries. This method will not commit the
533 operations. This method will not make any changes to in-memory data.
534 """
535 # Delete any groups
536 self.usergroupprojects_tbl.Delete(cnxn, group_id=ids, commit=False)
537 self.usergroupsettings_tbl.Delete(cnxn, group_id=ids, commit=False)
538 self.usergroup_tbl.Delete(cnxn, group_id=ids, commit=False)
539
540 # Delete any group members
541 self.usergroup_tbl.Delete(cnxn, user_id=ids, commit=False)
542
543
544class UserGroupDAG(object):
545 """A directed-acyclic graph of potentially nested user groups."""
546
547 def __init__(self, usergroup_service):
548 self.usergroup_service = usergroup_service
549 self.user_group_parents = collections.defaultdict(list)
550 self.user_group_children = collections.defaultdict(list)
551 self.initialized = False
552
553 def Build(self, cnxn, circle_detection=False):
554 if not self.initialized:
555 self.user_group_parents.clear()
556 self.user_group_children.clear()
557 group_ids = self.usergroup_service.usergroupsettings_tbl.Select(
558 cnxn, cols=['group_id'])
559 usergroup_rows = self.usergroup_service.usergroup_tbl.Select(
560 cnxn, cols=['user_id', 'group_id'], distinct=True,
561 user_id=[r[0] for r in group_ids])
562 for user_id, group_id in usergroup_rows:
563 self.user_group_parents[user_id].append(group_id)
564 self.user_group_children[group_id].append(user_id)
565 self.initialized = True
566
567 if circle_detection:
568 for child_id, parent_ids in self.user_group_parents.items():
569 for parent_id in parent_ids:
570 if self.IsChild(cnxn, parent_id, child_id):
571 logging.error(
572 'Circle exists between group %d and %d.', child_id, parent_id)
573
574 def GetAllAncestors(self, cnxn, group_id, circle_detection=False):
575 """Return a list of distinct ancestor group IDs for the given group."""
576 self.Build(cnxn, circle_detection)
577 result = set()
578 child_ids = [group_id]
579 while child_ids:
580 parent_ids = set()
581 for c_id in child_ids:
582 group_ids = self.user_group_parents[c_id]
583 parent_ids.update(g_id for g_id in group_ids if g_id not in result)
584 result.update(parent_ids)
585 child_ids = list(parent_ids)
586 return list(result)
587
588 def GetAllDescendants(self, cnxn, group_id, circle_detection=False):
589 """Return a list of distinct descendant group IDs for the given group."""
590 self.Build(cnxn, circle_detection)
591 result = set()
592 parent_ids = [group_id]
593 while parent_ids:
594 child_ids = set()
595 for p_id in parent_ids:
596 group_ids = self.user_group_children[p_id]
597 child_ids.update(g_id for g_id in group_ids if g_id not in result)
598 result.update(child_ids)
599 parent_ids = list(child_ids)
600 return list(result)
601
602 def IsChild(self, cnxn, child_id, parent_id):
603 """Returns True if child_id is a direct/indirect child of parent_id."""
604 all_descendants = self.GetAllDescendants(cnxn, parent_id)
605 return child_id in all_descendants
606
607 def MarkObsolete(self):
608 """Mark the DAG as uninitialized so it'll be re-built."""
609 self.initialized = False
610
611 def __repr__(self):
612 result = {}
613 result['parents'] = self.user_group_parents
614 result['children'] = self.user_group_children
615 return str(result)