Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(132)

Side by Side Diff: appengine/monorail/services/usergroup_svc.py

Issue 1868553004: Open Source Monorail (Closed) Base URL: https://chromium.googlesource.com/infra/infra.git@master
Patch Set: Rebase Created 4 years, 8 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
« no previous file with comments | « appengine/monorail/services/user_svc.py ('k') | appengine/monorail/settings.py » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
(Empty)
1 # Copyright 2016 The Chromium Authors. All rights reserved.
2 # Use of this source code is govered by a BSD-style
3 # license that can be found in the LICENSE file or at
4 # https://developers.google.com/open-source/licenses/bsd
5
6 """Persistence class for user groups.
7
8 User groups are represented in the database by:
9 - A row in the Users table giving an email address and user ID.
10 (A "group ID" is the user_id of the group in the User table.)
11 - A row in the UserGroupSettings table giving user group settings.
12
13 Membership of a user X in user group Y is represented as:
14 - A row in the UserGroup table with user_id=X and group_id=Y.
15 """
16
17 import collections
18 import logging
19 import re
20
21 from framework import permissions
22 from framework import sql
23 from proto import usergroup_pb2
24 from services import caches
25
26
27 USERGROUP_TABLE_NAME = 'UserGroup'
28 USERGROUPSETTINGS_TABLE_NAME = 'UserGroupSettings'
29 USERGROUPPROJECTS_TABLE_NAME = 'Group2Project'
30
31 USERGROUP_COLS = ['user_id', 'group_id', 'role']
32 USERGROUPSETTINGS_COLS = ['group_id', 'who_can_view_members',
33 'external_group_type', 'last_sync_time']
34 USERGROUPPROJECTS_COLS = ['group_id', 'project_id']
35
36
37 class MembershipTwoLevelCache(caches.AbstractTwoLevelCache):
38 """Class to manage RAM and memcache for each user's memberships."""
39
40 def __init__(self, cache_manager, usergroup_service, group_dag):
41 super(MembershipTwoLevelCache, self).__init__(
42 cache_manager, 'user', 'memberships:', None)
43 self.usergroup_service = usergroup_service
44 self.group_dag = group_dag
45
46 def _DeserializeMemberships(self, memberships_rows):
47 """Reserialize the DB results into a {user_id: {group_id}}."""
48 result_dict = collections.defaultdict(set)
49 for user_id, group_id in memberships_rows:
50 result_dict[user_id].add(group_id)
51
52 return result_dict
53
54 def FetchItems(self, cnxn, keys):
55 """On RAM and memcache miss, hit the database to get memberships."""
56 direct_memberships_rows = self.usergroup_service.usergroup_tbl.Select(
57 cnxn, cols=['user_id', 'group_id'], distinct=True,
58 user_id=keys)
59 memberships_set = set()
60 for c_id, p_id in direct_memberships_rows:
61 all_parents = self.group_dag.GetAllAncestors(cnxn, p_id, True)
62 all_parents.append(p_id)
63 memberships_set.update([(c_id, g_id) for g_id in all_parents])
64 retrieved_dict = self._DeserializeMemberships(list(memberships_set))
65
66 # Make sure that every requested user is in the result, and gets cached.
67 retrieved_dict.update(
68 (user_id, set()) for user_id in keys
69 if user_id not in retrieved_dict)
70 return retrieved_dict
71
72
73 class UserGroupService(object):
74 """The persistence layer for user group data."""
75
76 def __init__(self, cache_manager):
77 """Initialize this service so that it is ready to use.
78
79 Args:
80 cache_manager: local cache with distributed invalidation.
81 """
82 self.usergroup_tbl = sql.SQLTableManager(USERGROUP_TABLE_NAME)
83 self.usergroupsettings_tbl = sql.SQLTableManager(
84 USERGROUPSETTINGS_TABLE_NAME)
85 self.usergroupprojects_tbl = sql.SQLTableManager(
86 USERGROUPPROJECTS_TABLE_NAME)
87
88 self.group_dag = UserGroupDAG(self)
89
90 # Like a dictionary {user_id: {group_id}}
91 self.memberships_2lc = MembershipTwoLevelCache(
92 cache_manager, self, self.group_dag)
93
94 ### Group creation
95
96 def CreateGroup(self, cnxn, services, group_name, who_can_view_members,
97 ext_group_type=None, friend_projects=None):
98 """Create a new user group.
99
100 Args:
101 cnxn: connection to SQL database.
102 services: connections to backend services.
103 group_name: string email address of the group to create.
104 who_can_view_members: 'owners', 'members', or 'anyone'.
105 ext_group_type: The type of external group to import.
106 friend_projects: The project ids declared as group friends to view its
107 members.
108
109 Returns:
110 int group_id of the new group.
111 """
112 friend_projects = friend_projects or []
113 assert who_can_view_members in ('owners', 'members', 'anyone')
114 if ext_group_type:
115 ext_group_type = str(ext_group_type).lower()
116 assert ext_group_type in (
117 'chrome_infra_auth', 'mdb', 'baggins'), (
118 ext_group_type)
119 assert who_can_view_members == 'owners'
120 group_id = services.user.LookupUserID(
121 cnxn, group_name.lower(), autocreate=True, allowgroups=True)
122 group_settings = usergroup_pb2.MakeSettings(
123 who_can_view_members, ext_group_type, 0, friend_projects)
124 self.UpdateSettings(cnxn, group_id, group_settings)
125 return group_id
126
127 def DeleteGroups(self, cnxn, group_ids):
128 """Delete groups' members and settings. It will NOT delete user entries.
129
130 Args:
131 cnxn: connection to SQL database.
132 group_ids: list of group ids to delete.
133 """
134 member_ids_dict, owner_ids_dict = self.LookupMembers(cnxn, group_ids)
135 citizens_id_dict = collections.defaultdict(list)
136 for g_id, user_ids in member_ids_dict.iteritems():
137 citizens_id_dict[g_id].extend(user_ids)
138 for g_id, user_ids in owner_ids_dict.iteritems():
139 citizens_id_dict[g_id].extend(user_ids)
140 for g_id, citizen_ids in citizens_id_dict.iteritems():
141 logging.info('Deleting group %d', g_id)
142 # Remove group members, friend projects and settings
143 self.RemoveMembers(cnxn, g_id, citizen_ids)
144 self.usergroupprojects_tbl.Delete(cnxn, group_id=g_id)
145 self.usergroupsettings_tbl.Delete(cnxn, group_id=g_id)
146
147 def DetermineWhichUserIDsAreGroups(self, cnxn, user_ids):
148 """From a list of user IDs, identify potential user groups.
149
150 Args:
151 cnxn: connection to SQL database.
152 user_ids: list of user IDs to examine.
153
154 Returns:
155 A list with a subset of the given user IDs that are user groups
156 rather than individual users.
157 """
158 # It is a group if there is any entry in the UserGroupSettings table.
159 group_id_rows = self.usergroupsettings_tbl.Select(
160 cnxn, cols=['group_id'], group_id=user_ids)
161 group_ids = [row[0] for row in group_id_rows]
162 return group_ids
163
164 ### User memberships in groups
165
166 def LookupAllMemberships(self, cnxn, user_ids, use_cache=True):
167 """Lookup all the group memberships of a list of users.
168
169 Args:
170 cnxn: connection to SQL database.
171 user_ids: list of int user IDs to get memberships for.
172 use_cache: set to False to ignore cached values.
173
174 Returns:
175 A dict {user_id: {group_id}} for the given user_ids.
176 """
177 result_dict, missed_ids = self.memberships_2lc.GetAll(
178 cnxn, user_ids, use_cache=use_cache)
179 assert not missed_ids
180 return result_dict
181
182 def LookupMemberships(self, cnxn, user_id):
183 """Return a set of group_ids that this user is a member of."""
184 membership_dict = self.LookupAllMemberships(cnxn, [user_id])
185 return membership_dict[user_id]
186
187 ### Group member addition, removal, and retrieval
188
189 def RemoveMembers(self, cnxn, group_id, old_member_ids):
190 """Remove the given members/owners from the user group."""
191 self.usergroup_tbl.Delete(
192 cnxn, group_id=group_id, user_id=old_member_ids)
193
194 all_affected = self._GetAllMembersInList(cnxn, old_member_ids)
195
196 self.group_dag.MarkObsolete()
197 self.memberships_2lc.InvalidateAllKeys(cnxn, all_affected)
198
199 def UpdateMembers(self, cnxn, group_id, member_ids, new_role):
200 """Update role for given members/owners to the user group."""
201 # Circle detection
202 for mid in member_ids:
203 if self.group_dag.IsChild(cnxn, group_id, mid):
204 raise CircularGroupException(
205 '%s is already an ancestor of group %s.' % (mid, group_id))
206
207 self.usergroup_tbl.Delete(
208 cnxn, group_id=group_id, user_id=member_ids)
209 rows = [(member_id, group_id, new_role) for member_id in member_ids]
210 self.usergroup_tbl.InsertRows(
211 cnxn, ['user_id', 'group_id', 'role'], rows)
212
213 all_affected = self._GetAllMembersInList(cnxn, member_ids)
214
215 self.group_dag.MarkObsolete()
216 self.memberships_2lc.InvalidateAllKeys(cnxn, all_affected)
217
218 def _GetAllMembersInList(self, cnxn, group_ids):
219 """Get all direct/indirect members/owners in a list."""
220 children_member_ids, children_owner_ids = self.LookupAllMembers(
221 cnxn, group_ids)
222 all_members_owners = set()
223 all_members_owners.update(group_ids)
224 for users in children_member_ids.itervalues():
225 all_members_owners.update(users)
226 for users in children_owner_ids.itervalues():
227 all_members_owners.update(users)
228 return list(all_members_owners)
229
230 def LookupAllMembers(self, cnxn, group_ids):
231 """Retrieve user IDs of members/owners of any of the given groups
232 transitively."""
233 direct_member_rows = self.usergroup_tbl.Select(
234 cnxn, cols=['user_id', 'group_id', 'role'], distinct=True,
235 group_id=group_ids)
236 member_ids_dict = {}
237 owner_ids_dict = {}
238 for gid in group_ids:
239 all_descendants = self.group_dag.GetAllDescendants(cnxn, gid, True)
240 indirect_member_rows = self.usergroup_tbl.Select(
241 cnxn, cols=['user_id'], distinct=True,
242 group_id=all_descendants)
243
244 # Owners must have direct membership. All indirect users are members.
245 owner_ids_dict[gid] = [m[0] for m in direct_member_rows
246 if m[1] == gid and m[2] == 'owner']
247 member_ids_list = [r[0] for r in indirect_member_rows]
248 member_ids_list.extend([m[0] for m in direct_member_rows
249 if m[1] == gid and m[2] == 'member'])
250 member_ids_dict[gid] = list(set(member_ids_list))
251 return member_ids_dict, owner_ids_dict
252
253 def LookupMembers(self, cnxn, group_ids):
254 """"Retrieve user IDs of direct members/owners of any of the given groups.
255
256 Args:
257 cnxn: connection to SQL database.
258 group_ids: list of int user IDs for all user groups to be examined.
259
260 Returns:
261 A dict of member IDs, and a dict of owner IDs keyed by group id.
262 """
263 member_rows = self.usergroup_tbl.Select(
264 cnxn, cols=['user_id', 'group_id', 'role'], distinct=True,
265 group_id=group_ids)
266 member_ids_dict = {}
267 owner_ids_dict = {}
268 for gid in group_ids:
269 member_ids_dict[gid] = [row[0] for row in member_rows
270 if row[1] == gid and row[2] == 'member']
271 owner_ids_dict[gid] = [row[0] for row in member_rows
272 if row[1] == gid and row[2] == 'owner']
273 return member_ids_dict, owner_ids_dict
274
275 def ExpandAnyUserGroups(self, cnxn, user_ids):
276 """Transitively expand any user groups and return member user IDs.
277
278 Args:
279 cnxn: connection to SQL database.
280 user_ids: list of user IDs to check.
281
282 Returns:
283 A pair (individual_user_ids, transitive_ids). individual_user_ids
284 is a list of user IDs that were in the given user_ids list and
285 that identify individual members. transitive_ids is a list of
286 user IDs of the members of any user group in the given list of
287 user_ids and the individual members of any nested groups.
288 """
289 group_ids = self.DetermineWhichUserIDsAreGroups(cnxn, user_ids)
290 direct_ids = [uid for uid in user_ids if uid not in group_ids]
291 member_ids_dict, owner_ids_dict = self.LookupAllMembers(cnxn, group_ids)
292 indirect_ids = set()
293 for gid in group_ids:
294 indirect_ids.update(member_ids_dict[gid])
295 indirect_ids.update(owner_ids_dict[gid])
296
297 # Note: we return direct and indirect member IDs separately so that
298 # the email notification footer can give more a specific reason for
299 # why the user got an email. E.g., "You were Cc'd" vs. "You are a
300 # member of a user group that was Cc'd".
301 return direct_ids, list(indirect_ids)
302
303 def LookupVisibleMembers(
304 self, cnxn, group_id_list, perms, effective_ids, services):
305 """"Retrieve the list of user group direct member/owner IDs that the user
306 may see.
307
308 Args:
309 cnxn: connection to SQL database.
310 group_id_list: list of int user IDs for all user groups to be examined.
311 perms: optional PermissionSet for the user viewing this page.
312 effective_ids: set of int user IDs for that user and all
313 his/her group memberships.
314 services: backend services.
315
316 Returns:
317 A list of all the member IDs from any group that the user is allowed
318 to view.
319 """
320 settings_dict = self.GetAllGroupSettings(cnxn, group_id_list)
321 group_ids = settings_dict.keys()
322 (owned_project_ids, membered_project_ids,
323 contrib_project_ids) = services.project.GetUserRolesInAllProjects(
324 cnxn, effective_ids)
325 project_ids = owned_project_ids.union(
326 membered_project_ids).union(contrib_project_ids)
327 # We need to fetch all members/owners to determine whether the requester
328 # has permission to view.
329 direct_member_ids_dict, direct_owner_ids_dict = self.LookupMembers(
330 cnxn, group_ids)
331 all_member_ids_dict, all_owner_ids_dict = self.LookupAllMembers(
332 cnxn, group_ids)
333 visible_member_ids = {}
334 visible_owner_ids = {}
335 for gid in group_ids:
336 member_ids = all_member_ids_dict[gid]
337 owner_ids = all_owner_ids_dict[gid]
338
339 if permissions.CanViewGroup(perms, effective_ids, settings_dict[gid],
340 member_ids, owner_ids, project_ids):
341 visible_member_ids[gid] = direct_member_ids_dict[gid]
342 visible_owner_ids[gid] = direct_owner_ids_dict[gid]
343
344 return visible_member_ids, visible_owner_ids
345
346 ### Group settings
347
348 def GetAllUserGroupsInfo(self, cnxn):
349 """Fetch (addr, member_count, usergroup_settings) for all user groups."""
350 group_rows = self.usergroupsettings_tbl.Select(
351 cnxn, cols=['group_id', 'email', 'who_can_view_members',
352 'external_group_type', 'last_sync_time'],
353 left_joins=[('User ON UserGroupSettings.group_id = User.user_id', [])])
354 count_rows = self.usergroup_tbl.Select(
355 cnxn, cols=['group_id', 'COUNT(*)'],
356 group_by=['group_id'])
357 count_dict = dict(count_rows)
358 group_ids = [g[0] for g in group_rows]
359 friends_dict = self.GetAllGroupFriendProjects(cnxn, group_ids)
360
361 user_group_info_tuples = [
362 (email, count_dict.get(group_id, 0),
363 usergroup_pb2.MakeSettings(visiblity, group_type, last_sync_time,
364 friends_dict.get(group_id, [])),
365 group_id)
366 for (group_id, email, visiblity, group_type, last_sync_time)
367 in group_rows]
368 return user_group_info_tuples
369
370 def GetAllGroupSettings(self, cnxn, group_ids):
371 """Fetch {group_id: group_settings} for the specified groups."""
372 # TODO(jrobbins): add settings to control who can join, etc.
373 rows = self.usergroupsettings_tbl.Select(
374 cnxn, cols=USERGROUPSETTINGS_COLS, group_id=group_ids)
375 friends_dict = self.GetAllGroupFriendProjects(cnxn, group_ids)
376 settings_dict = {
377 group_id: usergroup_pb2.MakeSettings(
378 vis, group_type, last_sync_time, friends_dict.get(group_id, []))
379 for group_id, vis, group_type, last_sync_time in rows}
380 return settings_dict
381
382 def GetGroupSettings(self, cnxn, group_id):
383 """Retrieve group settings for the specified user group.
384
385 Args:
386 cnxn: connection to SQL database.
387 group_id: int user ID of the user group.
388
389 Returns:
390 A UserGroupSettings object, or None if no such group exists.
391 """
392 return self.GetAllGroupSettings(cnxn, [group_id]).get(group_id)
393
394 def UpdateSettings(self, cnxn, group_id, group_settings):
395 """Update the visiblity settings of the specified group."""
396 who_can_view_members = str(group_settings.who_can_view_members).lower()
397 ext_group_type = group_settings.ext_group_type
398 assert who_can_view_members in ('owners', 'members', 'anyone')
399 if ext_group_type:
400 ext_group_type = str(group_settings.ext_group_type).lower()
401 assert ext_group_type in (
402 'chrome_infra_auth', 'mdb', 'baggins'), (
403 ext_group_type)
404 assert who_can_view_members == 'owners'
405 self.usergroupsettings_tbl.InsertRow(
406 cnxn, group_id=group_id, who_can_view_members=who_can_view_members,
407 external_group_type=ext_group_type,
408 last_sync_time=group_settings.last_sync_time,
409 replace=True)
410 self.usergroupprojects_tbl.Delete(
411 cnxn, group_id=group_id)
412 if group_settings.friend_projects:
413 rows = [(group_id, p_id) for p_id in group_settings.friend_projects]
414 self.usergroupprojects_tbl.InsertRows(
415 cnxn, ['group_id', 'project_id'], rows)
416
417 def GetAllGroupFriendProjects(self, cnxn, group_ids):
418 """Get {group_id: [project_ids]} for the specified user groups."""
419 rows = self.usergroupprojects_tbl.Select(
420 cnxn, cols=USERGROUPPROJECTS_COLS, group_id=group_ids)
421 friends_dict = {}
422 for group_id, project_id in rows:
423 friends_dict.setdefault(group_id, []).append(project_id)
424 return friends_dict
425
426 def GetGroupFriendProjects(self, cnxn, group_id):
427 """Get a list of friend projects for the specified user group."""
428 return self.GetAllGroupFriendProjects(cnxn, [group_id]).get(group_id)
429
430 def ValidateFriendProjects(self, cnxn, services, friend_projects):
431 """Validate friend projects.
432
433 Returns:
434 A list of project ids if no errors, or an error message.
435 """
436 project_names = filter(None, re.split('; |, | |;|,', friend_projects))
437 id_dict = services.project.LookupProjectIDs(cnxn, project_names)
438 missed_projects = []
439 result = []
440 for p_name in project_names:
441 if p_name in id_dict:
442 result.append(id_dict[p_name])
443 else:
444 missed_projects.append(p_name)
445 error_msg = ''
446 if missed_projects:
447 error_msg = 'Project(s) %s do not exist' % ', '.join(missed_projects)
448 return None, error_msg
449 else:
450 return result, None
451
452 # TODO(jrobbins): re-implement FindUntrustedGroups()
453
454
455 class UserGroupDAG(object):
456 """A directed-acyclic graph of potentially nested user groups."""
457
458 def __init__(self, usergroup_service):
459 self.usergroup_service = usergroup_service
460 self.user_group_parents = collections.defaultdict(list)
461 self.user_group_children = collections.defaultdict(list)
462 self.initialized = False
463
464 def Build(self, cnxn, circle_detection=False):
465 if not self.initialized:
466 self.user_group_parents.clear()
467 self.user_group_children.clear()
468 group_ids = self.usergroup_service.usergroupsettings_tbl.Select(
469 cnxn, cols=['group_id'])
470 usergroup_rows = self.usergroup_service.usergroup_tbl.Select(
471 cnxn, cols=['user_id', 'group_id'], distinct=True,
472 user_id=[r[0] for r in group_ids])
473 for user_id, group_id in usergroup_rows:
474 self.user_group_parents[user_id].append(group_id)
475 self.user_group_children[group_id].append(user_id)
476 self.initialized = True
477
478 if circle_detection:
479 for child_id, parent_ids in self.user_group_parents.iteritems():
480 for parent_id in parent_ids:
481 if self.IsChild(cnxn, parent_id, child_id):
482 logging.error(
483 'Circle exists between group %d and %d.', child_id, parent_id)
484
485 def GetAllAncestors(self, cnxn, group_id, circle_detection=False):
486 """Return a list of distinct ancestor group IDs for the given group."""
487 self.Build(cnxn, circle_detection)
488 result = set()
489 child_ids = [group_id]
490 while child_ids:
491 parent_ids = set()
492 for c_id in child_ids:
493 group_ids = self.user_group_parents[c_id]
494 parent_ids.update(g_id for g_id in group_ids if g_id not in result)
495 result.update(parent_ids)
496 child_ids = list(parent_ids)
497 return list(result)
498
499 def GetAllDescendants(self, cnxn, group_id, circle_detection=False):
500 """Return a list of distinct descendant group IDs for the given group."""
501 self.Build(cnxn, circle_detection)
502 result = set()
503 parent_ids = [group_id]
504 while parent_ids:
505 child_ids = set()
506 for p_id in parent_ids:
507 group_ids = self.user_group_children[p_id]
508 child_ids.update(g_id for g_id in group_ids if g_id not in result)
509 result.update(child_ids)
510 parent_ids = list(child_ids)
511 return list(result)
512
513 def IsChild(self, cnxn, child_id, parent_id):
514 """Returns True if child_id is a direct/indirect child of parent_id."""
515 all_descendants = self.GetAllDescendants(cnxn, parent_id)
516 return child_id in all_descendants
517
518 def MarkObsolete(self):
519 """Mark the DAG as uninitialized so it'll be re-built."""
520 self.initialized = False
521
522 def __repr__(self):
523 result = {}
524 result['parents'] = self.user_group_parents
525 result['children'] = self.user_group_children
526 return str(result)
527
528
529 class Error(Exception):
530 """Base class for errors from this module."""
531 pass
532
533
534 class CircularGroupException(Error):
535 """Circular nested group exception."""
536 pass
537
538
539 class GroupExistsException(Error):
540 """Group already exists exception."""
541 pass
OLDNEW
« no previous file with comments | « appengine/monorail/services/user_svc.py ('k') | appengine/monorail/settings.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698