Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/framework/permissions.py b/framework/permissions.py
new file mode 100644
index 0000000..eb40dc7
--- /dev/null
+++ b/framework/permissions.py
@@ -0,0 +1,1242 @@
+# 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
+
+"""Classes and functions to implement permission checking.
+
+The main data structure is a simple map from (user role, project status,
+project_access_level) to specific perms.
+
+A perm is simply a string that indicates that the user has a given
+permission.  The servlets and templates can test whether the current
+user has permission to see a UI element or perform an action by
+testing for the presence of the corresponding perm in the user's
+permission set.
+
+The user role is one of admin, owner, member, outsider user, or anon.
+The project status is one of the project states defined in project_pb2,
+or a special constant defined below.  Likewise for access level.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import bisect
+import collections
+import logging
+import time
+
+import ezt
+
+import settings
+from framework import framework_bizobj
+from framework import framework_constants
+from proto import project_pb2
+from proto import site_pb2
+from proto import tracker_pb2
+from proto import usergroup_pb2
+from tracker import tracker_bizobj
+
+# Constants that define permissions.
+# Note that perms with a leading "_" can never be granted
+# to users who are not site admins.
+VIEW = 'View'
+EDIT_PROJECT = 'EditProject'
+CREATE_PROJECT = 'CreateProject'
+PUBLISH_PROJECT = '_PublishProject'  # for making "doomed" projects LIVE
+VIEW_DEBUG = '_ViewDebug'  # on-page debugging info
+EDIT_OTHER_USERS = '_EditOtherUsers'  # can edit other user's prefs, ban, etc.
+CUSTOMIZE_PROCESS = 'CustomizeProcess'  # can use some enterprise features
+VIEW_EXPIRED_PROJECT = '_ViewExpiredProject'  # view long-deleted projects
+# View the list of contributors even in hub-and-spoke projects.
+VIEW_CONTRIBUTOR_LIST = 'ViewContributorList'
+
+# Quota
+VIEW_QUOTA = 'ViewQuota'
+EDIT_QUOTA = 'EditQuota'
+
+# Permissions for editing user groups
+CREATE_GROUP = 'CreateGroup'
+EDIT_GROUP = 'EditGroup'
+DELETE_GROUP = 'DeleteGroup'
+VIEW_GROUP = 'ViewGroup'
+
+# Perms for Source tools
+# TODO(jrobbins): Monorail is just issue tracking with no version control, so
+# phase out use of the term "Commit", sometime after Monorail's initial launch.
+COMMIT = 'Commit'
+
+# Perms for issue tracking
+CREATE_ISSUE = 'CreateIssue'
+EDIT_ISSUE = 'EditIssue'
+EDIT_ISSUE_OWNER = 'EditIssueOwner'
+EDIT_ISSUE_SUMMARY = 'EditIssueSummary'
+EDIT_ISSUE_STATUS = 'EditIssueStatus'
+EDIT_ISSUE_CC = 'EditIssueCc'
+EDIT_ISSUE_APPROVAL = 'EditIssueApproval'
+DELETE_ISSUE = 'DeleteIssue'
+# This allows certain API clients to attribute comments to other users.
+# The permission is not offered in the UI, but it can be typed in as
+# a custom permission name.  The ID of the API client is also recorded.
+IMPORT_COMMENT = 'ImportComment'
+ADD_ISSUE_COMMENT = 'AddIssueComment'
+VIEW_INBOUND_MESSAGES = 'ViewInboundMessages'
+CREATE_HOTLIST = 'CreateHotlist'
+# Note, there is no separate DELETE_ATTACHMENT perm.  We
+# allow a user to delete an attachment iff they could soft-delete
+# the comment that holds the attachment.
+
+# Note: the "_" in the perm name makes it impossible for a
+# project owner to grant it to anyone as an extra perm.
+ADMINISTER_SITE = '_AdministerSite'
+
+# Permissions to soft-delete artifact comment
+DELETE_ANY = 'DeleteAny'
+DELETE_OWN = 'DeleteOwn'
+
+# Granting this allows owners to delegate some team management work.
+EDIT_ANY_MEMBER_NOTES = 'EditAnyMemberNotes'
+
+# Permission to star/unstar any artifact.
+SET_STAR = 'SetStar'
+
+# Permission to flag any artifact as spam.
+FLAG_SPAM = 'FlagSpam'
+VERDICT_SPAM = 'VerdictSpam'
+MODERATE_SPAM = 'ModerateSpam'
+
+# Permissions for custom fields.
+EDIT_FIELD_DEF = 'EditFieldDef'
+EDIT_FIELD_DEF_VALUE = 'EditFieldDefValue'
+
+# Permissions for user hotlists.
+ADMINISTER_HOTLIST = 'AdministerHotlist'
+EDIT_HOTLIST = 'EditHotlist'
+VIEW_HOTLIST = 'ViewHotlist'
+HOTLIST_OWNER_PERMISSIONS = [ADMINISTER_HOTLIST, EDIT_HOTLIST]
+HOTLIST_EDITOR_PERMISSIONS = [EDIT_HOTLIST]
+
+RESTRICTED_APPROVAL_STATUSES = [
+    tracker_pb2.ApprovalStatus.NA,
+    tracker_pb2.ApprovalStatus.APPROVED,
+    tracker_pb2.ApprovalStatus.NOT_APPROVED]
+
+STANDARD_ADMIN_PERMISSIONS = [
+    EDIT_PROJECT, CREATE_PROJECT, PUBLISH_PROJECT, VIEW_DEBUG,
+    EDIT_OTHER_USERS, CUSTOMIZE_PROCESS,
+    VIEW_QUOTA, EDIT_QUOTA, ADMINISTER_SITE,
+    EDIT_ANY_MEMBER_NOTES, VERDICT_SPAM, MODERATE_SPAM]
+
+STANDARD_ISSUE_PERMISSIONS = [
+    VIEW, EDIT_ISSUE, ADD_ISSUE_COMMENT, DELETE_ISSUE, FLAG_SPAM]
+
+# Monorail has no source control, but keep COMMIT for backward compatability.
+STANDARD_SOURCE_PERMISSIONS = [COMMIT]
+
+STANDARD_COMMENT_PERMISSIONS = [DELETE_OWN, DELETE_ANY]
+
+STANDARD_OTHER_PERMISSIONS = [CREATE_ISSUE, FLAG_SPAM, SET_STAR]
+
+STANDARD_PERMISSIONS = (STANDARD_ADMIN_PERMISSIONS +
+                        STANDARD_ISSUE_PERMISSIONS +
+                        STANDARD_SOURCE_PERMISSIONS +
+                        STANDARD_COMMENT_PERMISSIONS +
+                        STANDARD_OTHER_PERMISSIONS)
+
+# roles
+SITE_ADMIN_ROLE = 'admin'
+OWNER_ROLE = 'owner'
+COMMITTER_ROLE = 'committer'
+CONTRIBUTOR_ROLE = 'contributor'
+USER_ROLE = 'user'
+ANON_ROLE = 'anon'
+
+# Project state out-of-band values for keys
+UNDEFINED_STATUS = 'undefined_status'
+UNDEFINED_ACCESS = 'undefined_access'
+WILDCARD_ACCESS = 'wildcard_access'
+
+
+class PermissionSet(object):
+  """Class to represent the set of permissions available to the user."""
+
+  def __init__(self, perm_names, consider_restrictions=True):
+    """Create a PermissionSet with the given permissions.
+
+    Args:
+      perm_names: a list of permission name strings.
+      consider_restrictions: if true, the user's permissions can be blocked
+          by restriction labels on an artifact.  Project owners and site
+          admins do not consider restrictions so that they cannot
+          "lock themselves out" of editing an issue.
+    """
+    self.perm_names = frozenset(p.lower() for p in perm_names)
+    self.consider_restrictions = consider_restrictions
+
+  def __getattr__(self, perm_name):
+    """Easy permission testing in EZT.  E.g., [if-any perms.format_drive]."""
+    return ezt.boolean(self.HasPerm(perm_name, None, None))
+
+  def CanUsePerm(
+      self, perm_name, effective_ids, project, restriction_labels,
+      granted_perms=None):
+    """Return True if the user can use the given permission.
+
+    Args:
+      perm_name: string name of permission, e.g., 'EditIssue'.
+      effective_ids: set of int user IDs for the user (including any groups),
+          or an empty set if user is not signed in.
+      project: Project PB for the project being accessed, or None if not
+          in a project.
+      restriction_labels: list of strings that restrict permission usage.
+      granted_perms: optional list of lowercase strings of permissions that the
+          user is granted only within the scope of one issue, e.g., by being
+          named in a user-type custom field that grants permissions.
+
+    Restriction labels have 3 parts, e.g.:
+    'Restrict-EditIssue-InnerCircle' blocks the use of just the
+    EditIssue permission, unless the user also has the InnerCircle
+    permission.  This allows fine-grained restrictions on specific
+    actions, such as editing, commenting, or deleting.
+
+    Restriction labels and permissions are case-insensitive.
+
+    Returns:
+      True if the user can use the given permission, or False
+      if they cannot (either because they don't have that permission
+      or because it is blocked by a relevant restriction label).
+    """
+    # TODO(jrobbins): room for performance improvement: avoid set creation and
+    # repeated string operations.
+    granted_perms = granted_perms or set()
+    perm_lower = perm_name.lower()
+    if perm_lower in granted_perms:
+      return True
+
+    needed_perms = {perm_lower}
+    if self.consider_restrictions:
+      for label in restriction_labels:
+        label = label.lower()
+        # format: Restrict-Action-ToThisPerm
+        _kw, requested_perm, needed_perm = label.split('-', 2)
+        if requested_perm == perm_lower and needed_perm not in granted_perms:
+          needed_perms.add(needed_perm)
+
+    if not effective_ids:
+      effective_ids = {framework_constants.NO_USER_SPECIFIED}
+
+    # Get all extra perms for all effective ids.
+    # Id X might have perm A and Y might have B, if both A and B are needed
+    # True should be returned.
+    extra_perms = set()
+    for user_id in effective_ids:
+      extra_perms.update(p.lower() for p in GetExtraPerms(project, user_id))
+    return all(self.HasPerm(perm, None, None, extra_perms)
+               for perm in needed_perms)
+
+  def HasPerm(self, perm_name, user_id, project, extra_perms=None):
+    """Return True if the user has the given permission (ignoring user groups).
+
+    Args:
+      perm_name: string name of permission, e.g., 'EditIssue'.
+      user_id: int user id of the user, or None if user is not signed in.
+      project: Project PB for the project being accessed, or None if not
+          in a project.
+      extra_perms: list of extra perms. If not given, GetExtraPerms will be
+          called to get them.
+
+    Returns:
+      True if the user has the given perm.
+    """
+    perm_name = perm_name.lower()
+
+    # Return early if possible.
+    if perm_name in self.perm_names:
+      return True
+
+    if extra_perms is None:
+      # TODO(jrobbins): room for performance improvement: pre-compute
+      # extra perms (maybe merge them into the perms object), avoid
+      # redundant call to lower().
+      return any(
+          p.lower() == perm_name
+          for p in GetExtraPerms(project, user_id))
+
+    return perm_name in extra_perms
+
+  def DebugString(self):
+    """Return a useful string to show when debugging."""
+    return 'PermissionSet(%s)' % ', '.join(sorted(self.perm_names))
+
+  def __repr__(self):
+    return '%s(%r)' % (self.__class__.__name__, self.perm_names)
+
+
+EMPTY_PERMISSIONSET = PermissionSet([])
+
+READ_ONLY_PERMISSIONSET = PermissionSet([VIEW])
+
+USER_PERMISSIONSET = PermissionSet([
+    VIEW, FLAG_SPAM, SET_STAR,
+    CREATE_ISSUE, ADD_ISSUE_COMMENT,
+    DELETE_OWN])
+
+CONTRIBUTOR_ACTIVE_PERMISSIONSET = PermissionSet(
+    [VIEW,
+     FLAG_SPAM, VERDICT_SPAM, SET_STAR,
+     CREATE_ISSUE, ADD_ISSUE_COMMENT,
+     DELETE_OWN])
+
+CONTRIBUTOR_INACTIVE_PERMISSIONSET = PermissionSet(
+    [VIEW])
+
+COMMITTER_ACTIVE_PERMISSIONSET = PermissionSet(
+    [VIEW, COMMIT, VIEW_CONTRIBUTOR_LIST,
+     FLAG_SPAM, VERDICT_SPAM, SET_STAR, VIEW_QUOTA,
+     CREATE_ISSUE, ADD_ISSUE_COMMENT, EDIT_ISSUE, VIEW_INBOUND_MESSAGES,
+     DELETE_OWN])
+
+COMMITTER_INACTIVE_PERMISSIONSET = PermissionSet(
+    [VIEW, VIEW_CONTRIBUTOR_LIST,
+     VIEW_INBOUND_MESSAGES, VIEW_QUOTA])
+
+OWNER_ACTIVE_PERMISSIONSET = PermissionSet(
+    [VIEW, VIEW_CONTRIBUTOR_LIST, EDIT_PROJECT, COMMIT,
+     FLAG_SPAM, VERDICT_SPAM, SET_STAR, VIEW_QUOTA,
+     CREATE_ISSUE, ADD_ISSUE_COMMENT, EDIT_ISSUE, DELETE_ISSUE,
+     VIEW_INBOUND_MESSAGES,
+     DELETE_ANY, EDIT_ANY_MEMBER_NOTES],
+    consider_restrictions=False)
+
+OWNER_INACTIVE_PERMISSIONSET = PermissionSet(
+    [VIEW, VIEW_CONTRIBUTOR_LIST, EDIT_PROJECT,
+     VIEW_INBOUND_MESSAGES, VIEW_QUOTA],
+    consider_restrictions=False)
+
+ADMIN_PERMISSIONSET = PermissionSet(
+    [VIEW, VIEW_CONTRIBUTOR_LIST,
+     CREATE_PROJECT, EDIT_PROJECT, PUBLISH_PROJECT, VIEW_DEBUG,
+     COMMIT, CUSTOMIZE_PROCESS, FLAG_SPAM, VERDICT_SPAM, SET_STAR,
+     ADMINISTER_SITE, VIEW_EXPIRED_PROJECT, EDIT_OTHER_USERS,
+     VIEW_QUOTA, EDIT_QUOTA,
+     CREATE_ISSUE, ADD_ISSUE_COMMENT, EDIT_ISSUE, DELETE_ISSUE,
+     EDIT_ISSUE_APPROVAL,
+     VIEW_INBOUND_MESSAGES,
+     DELETE_ANY, EDIT_ANY_MEMBER_NOTES,
+     CREATE_GROUP, EDIT_GROUP, DELETE_GROUP, VIEW_GROUP,
+     MODERATE_SPAM, CREATE_HOTLIST],
+     consider_restrictions=False)
+
+GROUP_IMPORT_BORG_PERMISSIONSET = PermissionSet(
+    [CREATE_GROUP, VIEW_GROUP, EDIT_GROUP])
+
+# Permissions for project pages, e.g., the project summary page
+_PERMISSIONS_TABLE = {
+
+    # Project owners can view and edit artifacts in a LIVE project.
+    (OWNER_ROLE, project_pb2.ProjectState.LIVE, WILDCARD_ACCESS):
+      OWNER_ACTIVE_PERMISSIONSET,
+
+    # Project owners can view, but not edit artifacts in ARCHIVED.
+    # Note: EDIT_PROJECT is not enough permission to change an ARCHIVED project
+    # back to LIVE if a delete_time was set.
+    (OWNER_ROLE, project_pb2.ProjectState.ARCHIVED, WILDCARD_ACCESS):
+      OWNER_INACTIVE_PERMISSIONSET,
+
+    # Project members can view their own project, regardless of state.
+    (COMMITTER_ROLE, project_pb2.ProjectState.LIVE, WILDCARD_ACCESS):
+      COMMITTER_ACTIVE_PERMISSIONSET,
+    (COMMITTER_ROLE, project_pb2.ProjectState.ARCHIVED, WILDCARD_ACCESS):
+      COMMITTER_INACTIVE_PERMISSIONSET,
+
+    # Project contributors can view their own project, regardless of state.
+    (CONTRIBUTOR_ROLE, project_pb2.ProjectState.LIVE, WILDCARD_ACCESS):
+      CONTRIBUTOR_ACTIVE_PERMISSIONSET,
+    (CONTRIBUTOR_ROLE, project_pb2.ProjectState.ARCHIVED, WILDCARD_ACCESS):
+      CONTRIBUTOR_INACTIVE_PERMISSIONSET,
+
+    # Non-members users can read and comment in projects with access == ANYONE
+    (USER_ROLE, project_pb2.ProjectState.LIVE,
+     project_pb2.ProjectAccess.ANYONE):
+      USER_PERMISSIONSET,
+
+    # Anonymous users can only read projects with access == ANYONE.
+    (ANON_ROLE, project_pb2.ProjectState.LIVE,
+     project_pb2.ProjectAccess.ANYONE):
+      READ_ONLY_PERMISSIONSET,
+
+    # Permissions for site pages, e.g., creating a new project
+    (USER_ROLE, UNDEFINED_STATUS, UNDEFINED_ACCESS):
+      PermissionSet([CREATE_PROJECT, CREATE_GROUP, CREATE_HOTLIST]),
+    }
+
+def GetPermissions(user, effective_ids, project):
+  """Return a permission set appropriate for the user and project.
+
+  Args:
+    user: The User PB for the signed-in user, or None for anon users.
+    effective_ids: set of int user IDs for the current user and all user
+        groups that they are a member of.  This will be an empty set for
+        anonymous users.
+    project: either a Project protobuf, or None for a page whose scope is
+        wider than a single project.
+
+  Returns:
+    a PermissionSet object for the current user and project (or for
+    site-wide operations if project is None).
+
+  If an exact match for the user's role and project status is found, that is
+  returned. Otherwise, we look for permissions for the user's role that is
+  not specific to any project status, or not specific to any project access
+  level.  If neither of those are defined, we give the user an empty
+  permission set.
+  """
+  # Site admins get ADMIN_PERMISSIONSET regardless of groups or projects.
+  if user and user.is_site_admin:
+    return ADMIN_PERMISSIONSET
+
+  # Grant the borg job permission to view/edit groups
+  if user and user.email == settings.borg_service_account:
+    return GROUP_IMPORT_BORG_PERMISSIONSET
+
+  # Anon users don't need to accumulate anything.
+  if not effective_ids:
+    role, status, access = _GetPermissionKey(None, project)
+    return _LookupPermset(role, status, access)
+
+  effective_perms = set()
+  consider_restrictions = True
+
+  # Check for signed-in user with no roles in the current project.
+  if not project or not framework_bizobj.UserIsInProject(
+      project, effective_ids):
+    role, status, access = _GetPermissionKey(None, project)
+    return _LookupPermset(USER_ROLE, status, access)
+
+  # Signed-in user gets the union of all their PermissionSets from the table.
+  for user_id in effective_ids:
+    role, status, access = _GetPermissionKey(user_id, project)
+    role_perms = _LookupPermset(role, status, access)
+    # Accumulate a union of all the user's permissions.
+    effective_perms.update(role_perms.perm_names)
+    # If any role allows the user to ignore restriction labels, then
+    # ignore them overall.
+    if not role_perms.consider_restrictions:
+      consider_restrictions = False
+
+  return PermissionSet(
+      effective_perms, consider_restrictions=consider_restrictions)
+
+
+def UpdateIssuePermissions(
+    perms, project, issue, effective_ids, granted_perms=None, config=None):
+  """Update the PermissionSet for an specific issue.
+
+  Take into account granted permissions and label restrictions to filter the
+  permissions, and updates the VIEW and EDIT_ISSUE permissions depending on the
+  role of the user in the issue (i.e. owner, reporter, cc or approver).
+
+  Args:
+    perms: The PermissionSet to update.
+    project: The Project PB for the issue project.
+    issue: The Issue PB.
+    effective_ids: Set of int user IDs for the current user and all user
+        groups that they are a member of.  This will be an empty set for
+        anonymous users.
+    granted_perms: optional list of strings of permissions that the user is
+        granted only within the scope of one issue, e.g., by being named in
+        a user-type custom field that grants permissions.
+    config: optional ProjectIssueConfig PB where granted perms should be
+        extracted from, if granted_perms is not given.
+  """
+  if config:
+    granted_perms = tracker_bizobj.GetGrantedPerms(
+        issue, effective_ids, config)
+  elif granted_perms is None:
+    granted_perms = []
+
+  # If the user has no permission to view the project, it has no permissions on
+  # this issue.
+  if not perms.HasPerm(VIEW, None, None):
+    return EMPTY_PERMISSIONSET
+
+  # Compute the restrictions for the given issue and store them in a dictionary
+  # of {perm: set(needed_perms)}.
+  restrictions = collections.defaultdict(set)
+  if perms.consider_restrictions:
+    for label in GetRestrictions(issue):
+      label = label.lower()
+      # format: Restrict-Action-ToThisPerm
+      _, requested_perm, needed_perm = label.split('-', 2)
+      restrictions[requested_perm.lower()].add(needed_perm.lower())
+
+  # Store the user permissions, and the extra permissions of all effective IDs
+  # in the given project.
+  all_perms = set(perms.perm_names)
+  for effective_id in effective_ids:
+    all_perms.update(p.lower() for p in GetExtraPerms(project, effective_id))
+
+  # And filter them applying the restriction labels.
+  filtered_perms = set()
+  for perm_name in all_perms:
+    perm_name = perm_name.lower()
+    restricted = any(
+        restriction not in all_perms and restriction not in granted_perms
+        for restriction in restrictions.get(perm_name, []))
+    if not restricted:
+      filtered_perms.add(perm_name)
+
+  # Add any granted permissions.
+  filtered_perms.update(granted_perms)
+
+  # The VIEW perm might have been removed due to restrictions, but the issue
+  # owner, reporter, cc and approvers can always be an issue.
+  allowed_ids = set(
+      tracker_bizobj.GetCcIds(issue)
+      + tracker_bizobj.GetApproverIds(issue)
+      + [issue.reporter_id, tracker_bizobj.GetOwnerId(issue)])
+  if effective_ids and not allowed_ids.isdisjoint(effective_ids):
+    filtered_perms.add(VIEW.lower())
+
+  # If the issue is deleted, only the VIEW and DELETE_ISSUE permissions are
+  # relevant.
+  if issue.deleted:
+    if VIEW.lower() not in filtered_perms:
+      return EMPTY_PERMISSIONSET
+    if DELETE_ISSUE.lower() in filtered_perms:
+      return PermissionSet([VIEW, DELETE_ISSUE], perms.consider_restrictions)
+    return PermissionSet([VIEW], perms.consider_restrictions)
+
+  # The EDIT_ISSUE permission might have been removed due to restrictions, but
+  # the owner always has permission to edit it.
+  if effective_ids and tracker_bizobj.GetOwnerId(issue) in effective_ids:
+    filtered_perms.add(EDIT_ISSUE.lower())
+
+  return PermissionSet(filtered_perms, perms.consider_restrictions)
+
+
+def _LookupPermset(role, status, access):
+  """Lookup the appropriate PermissionSet in _PERMISSIONS_TABLE.
+
+  Args:
+    role: a string indicating the user's role in the project.
+    status: a Project PB status value, or UNDEFINED_STATUS.
+    access: a Project PB access value, or UNDEFINED_ACCESS.
+
+  Returns:
+    A PermissionSet that is appropriate for that kind of user in that
+    project context.
+  """
+  if (role, status, access) in _PERMISSIONS_TABLE:
+    return _PERMISSIONS_TABLE[(role, status, access)]
+  elif (role, status, WILDCARD_ACCESS) in _PERMISSIONS_TABLE:
+    return _PERMISSIONS_TABLE[(role, status, WILDCARD_ACCESS)]
+  else:
+    return EMPTY_PERMISSIONSET
+
+
+def _GetPermissionKey(user_id, project, expired_before=None):
+  """Return a permission lookup key appropriate for the user and project."""
+  if user_id is None:
+    role = ANON_ROLE
+  elif project and IsExpired(project, expired_before=expired_before):
+    role = USER_ROLE  # Do not honor roles in expired projects.
+  elif project and user_id in project.owner_ids:
+    role = OWNER_ROLE
+  elif project and user_id in project.committer_ids:
+    role = COMMITTER_ROLE
+  elif project and user_id in project.contributor_ids:
+    role = CONTRIBUTOR_ROLE
+  else:
+    role = USER_ROLE
+
+  if project is None:
+    status = UNDEFINED_STATUS
+  else:
+    status = project.state
+
+  if project is None:
+    access = UNDEFINED_ACCESS
+  else:
+    access = project.access
+
+  return role, status, access
+
+
+def GetExtraPerms(project, member_id):
+  """Return a list of extra perms for the user in the project.
+
+  Args:
+    project: Project PB for the current project.
+    member_id: user id of a project owner, member, or contributor.
+
+  Returns:
+    A list of strings for the extra perms granted to the
+    specified user in this project.  The list will often be empty.
+  """
+
+  _, extra_perms = FindExtraPerms(project, member_id)
+
+  if extra_perms:
+    return list(extra_perms.perms)
+  else:
+    return []
+
+
+def FindExtraPerms(project, member_id):
+  """Return a ExtraPerms PB for the given user in the project.
+
+  Args:
+    project: Project PB for the current project, or None if the user is
+      not currently in a project.
+    member_id: user ID of a project owner, member, or contributor.
+
+  Returns:
+    A pair (idx, extra_perms).
+    * If project is None or member_id is not part of the project, both are None.
+    * If member_id has no extra_perms, extra_perms is None, and idx points to
+      the position where it should go to keep the ExtraPerms sorted in project.
+    * Otherwise, idx is the position of member_id in the project's extra_perms,
+      and extra_perms is an ExtraPerms PB.
+  """
+  class ExtraPermsView(object):
+    def __len__(self):
+      return len(project.extra_perms)
+    def __getitem__(self, idx):
+      return project.extra_perms[idx].member_id
+
+  if not project:
+    # TODO(jrobbins): maybe define extra perms for site-wide operations.
+    return None, None
+
+  # Users who have no current role cannot have any extra perms.  Don't
+  # consider effective_ids (which includes user groups) for this check.
+  if not framework_bizobj.UserIsInProject(project, {member_id}):
+    return None, None
+
+  extra_perms_view = ExtraPermsView()
+  # Find the index of the first extra_perms.member_id greater than or equal to
+  # member_id.
+  idx = bisect.bisect_left(extra_perms_view, member_id)
+  if idx >= len(project.extra_perms) or extra_perms_view[idx] > member_id:
+    return idx, None
+  return idx, project.extra_perms[idx]
+
+
+def GetCustomPermissions(project):
+  """Return a sorted iterable of custom perms granted in a project."""
+  custom_permissions = set()
+  for extra_perms in project.extra_perms:
+    for perm in extra_perms.perms:
+      if perm not in STANDARD_PERMISSIONS:
+        custom_permissions.add(perm)
+
+  return sorted(custom_permissions)
+
+
+def UserCanViewProject(user, effective_ids, project, expired_before=None):
+  """Return True if the user can view the given project.
+
+  Args:
+    user: User protobuf for the user trying to view the project.
+    effective_ids: set of int user IDs of the user trying to view the project
+        (including any groups), or an empty set for anonymous users.
+    project: the Project protobuf to check.
+    expired_before: option time value for testing.
+
+  Returns:
+    True if the user should be allowed to view the project.
+  """
+  perms = GetPermissions(user, effective_ids, project)
+
+  if IsExpired(project, expired_before=expired_before):
+    needed_perm = VIEW_EXPIRED_PROJECT
+  else:
+    needed_perm = VIEW
+
+  return perms.CanUsePerm(needed_perm, effective_ids, project, [])
+
+
+def IsExpired(project, expired_before=None):
+  """Return True if a project deletion has been pending long enough already.
+
+  Args:
+    project: The project being viewed.
+    expired_before: If supplied, this method will return True only if the
+      project expired before the given time.
+
+  Returns:
+    True if the project is eligible for reaping.
+  """
+  if project.state != project_pb2.ProjectState.ARCHIVED:
+    return False
+
+  if expired_before is None:
+    expired_before = int(time.time())
+
+  return project.delete_time and project.delete_time < expired_before
+
+
+def CanDeleteComment(comment, commenter, user_id, perms):
+  """Returns true if the user can (un)delete the given comment.
+
+  UpdateIssuePermissions must have been called first.
+
+  Args:
+    comment: An IssueComment PB object.
+    commenter: An User PB object with the user who created the comment.
+    user_id: The ID of the user whose permission we want to check.
+    perms: The PermissionSet with the issue permissions.
+
+  Returns:
+    True if the user can (un)delete the comment.
+  """
+  # User is not logged in or has no permissions.
+  if not user_id or not perms:
+    return False
+
+  # Nobody can (un)delete comments by banned users or spam comments, which
+  # should be un-flagged instead.
+  if commenter.banned or comment.is_spam:
+    return False
+
+  # Site admin or project owners can delete any comment.
+  permit_delete_any = perms.HasPerm(DELETE_ANY, None, None, [])
+  if permit_delete_any:
+    return True
+
+  # Users cannot undelete unless they deleted.
+  if comment.deleted_by and comment.deleted_by != user_id:
+    return False
+
+  # Users can delete their own items.
+  permit_delete_own = perms.HasPerm(DELETE_OWN, None, None, [])
+  if permit_delete_own and comment.user_id == user_id:
+    return True
+
+  return False
+
+
+def CanFlagComment(comment, commenter, comment_reporters, user_id, perms):
+  """Returns true if the user can flag the given comment.
+
+  UpdateIssuePermissions must have been called first.
+  Assumes that the user has permission to view the issue.
+
+  Args:
+    comment: An IssueComment PB object.
+    commenter: An User PB object with the user who created the comment.
+    comment_reporters: A collection of user IDs who flagged the comment as spam.
+    user_id: The ID of the user for whom we're checking permissions.
+    perms: The PermissionSet with the issue permissions.
+
+  Returns:
+    A tuple (can_flag, is_flagged).
+    can_flag is True if the user can flag the comment. and is_flagged is True
+    if the user sees the comment marked as spam.
+  """
+  # Nobody can flag comments by banned users.
+  if commenter.banned:
+    return False, comment.is_spam
+
+  # If a comment was deleted for a reason other than being spam, nobody can
+  # flag or un-flag it.
+  if comment.deleted_by and not comment.is_spam:
+    return False, comment.is_spam
+
+  # A user with the VerdictSpam permission sees whether the comment is flagged
+  # as spam or not, and can mark it as flagged or un-flagged.
+  # If the comment is flagged as spam, all users see it as flagged, but only
+  # those with the VerdictSpam can un-flag it.
+  permit_verdict_spam = perms.HasPerm(VERDICT_SPAM, None, None, [])
+  if permit_verdict_spam or comment.is_spam:
+    return permit_verdict_spam, comment.is_spam
+
+  # Otherwise, the comment is not marked as flagged and the user doesn't have
+  # the VerdictSpam permission.
+  # They are able to report a comment as spam if they have the FlagSpam
+  # permission, and they see the comment as flagged if the have previously
+  # reported it as spam.
+  permit_flag_spam = perms.HasPerm(FLAG_SPAM, None, None, [])
+  return permit_flag_spam, user_id in comment_reporters
+
+
+def CanViewComment(comment, commenter, user_id, perms):
+  """Returns true if the user can view the given comment.
+
+  UpdateIssuePermissions must have been called first.
+  Assumes that the user has permission to view the issue.
+
+  Args:
+    comment: An IssueComment PB object.
+    commenter: An User PB object with the user who created the comment.
+    user_id: The ID of the user whose permission we want to check.
+    perms: The PermissionSet with the issue permissions.
+
+  Returns:
+    True if the user can view the comment.
+  """
+  # Nobody can view comments by banned users.
+  if commenter.banned:
+    return False
+
+  # Only users with the permission to un-flag comments can view flagged
+  # comments.
+  if comment.is_spam:
+    # If the comment is marked as spam, whether the user can un-flag the comment
+    # or not doesn't depend on who reported it as spam.
+    can_flag, _ = CanFlagComment(comment, commenter, [], user_id, perms)
+    return can_flag
+
+  # Only users with the permission to un-delete comments can view deleted
+  # comments.
+  if comment.deleted_by:
+    return CanDeleteComment(comment, commenter, user_id, perms)
+
+  return True
+
+
+def CanViewInboundMessage(comment, user_id, perms):
+  """Returns true if the user can view the given comment's inbound message.
+
+  UpdateIssuePermissions must have been called first.
+  Assumes that the user has permission to view the comment.
+
+  Args:
+    comment: An IssueComment PB object.
+    commenter: An User PB object with the user who created the comment.
+    user_id: The ID of the user whose permission we want to check.
+    perms: The PermissionSet with the issue permissions.
+
+  Returns:
+    True if the user can view the comment's inbound message.
+  """
+  return (perms.HasPerm(VIEW_INBOUND_MESSAGES, None, None, [])
+          or comment.user_id == user_id)
+
+
+def CanView(effective_ids, perms, project, restrictions, granted_perms=None):
+  """Checks if user has permission to view an issue."""
+  return perms.CanUsePerm(
+      VIEW, effective_ids, project, restrictions, granted_perms=granted_perms)
+
+
+def CanCreateProject(perms):
+  """Return True if the given user may create a project.
+
+  Args:
+    perms: Permissionset for the current user.
+
+  Returns:
+    True if the user should be allowed to create a project.
+  """
+  # "ANYONE" means anyone who has the needed perm.
+  if (settings.project_creation_restriction ==
+      site_pb2.UserTypeRestriction.ANYONE):
+    return perms.HasPerm(CREATE_PROJECT, None, None)
+
+  if (settings.project_creation_restriction ==
+      site_pb2.UserTypeRestriction.ADMIN_ONLY):
+    return perms.HasPerm(ADMINISTER_SITE, None, None)
+
+  return False
+
+
+def CanCreateGroup(perms):
+  """Return True if the given user may create a user group.
+
+  Args:
+    perms: Permissionset for the current user.
+
+  Returns:
+    True if the user should be allowed to create a group.
+  """
+  # "ANYONE" means anyone who has the needed perm.
+  if (settings.group_creation_restriction ==
+      site_pb2.UserTypeRestriction.ANYONE):
+    return perms.HasPerm(CREATE_GROUP, None, None)
+
+  if (settings.group_creation_restriction ==
+      site_pb2.UserTypeRestriction.ADMIN_ONLY):
+    return perms.HasPerm(ADMINISTER_SITE, None, None)
+
+  return False
+
+
+def CanEditGroup(perms, effective_ids, group_owner_ids):
+  """Return True if the given user may edit a user group.
+
+  Args:
+    perms: Permissionset for the current user.
+    effective_ids: set of user IDs for the logged in user.
+    group_owner_ids: set of user IDs of the user group owners.
+
+  Returns:
+    True if the user should be allowed to edit the group.
+  """
+  return (perms.HasPerm(EDIT_GROUP, None, None) or
+          not effective_ids.isdisjoint(group_owner_ids))
+
+
+def CanViewGroupMembers(perms, effective_ids, group_settings, member_ids,
+                        owner_ids, user_project_ids):
+  """Return True if the given user may view a user group's members.
+
+  Args:
+    perms: Permissionset for the current user.
+    effective_ids: set of user IDs for the logged in user.
+    group_settings: PB of UserGroupSettings.
+    member_ids: A list of member ids of this user group.
+    owner_ids: A list of owner ids of this user group.
+    user_project_ids: A list of project ids which the user has a role.
+
+  Returns:
+    True if the user should be allowed to view the group's members.
+  """
+  if perms.HasPerm(VIEW_GROUP, None, None):
+    return True
+  # The user could view this group with membership of some projects which are
+  # friends of the group.
+  if (group_settings.friend_projects and user_project_ids
+      and (set(group_settings.friend_projects) & set(user_project_ids))):
+    return True
+  visibility = group_settings.who_can_view_members
+  if visibility == usergroup_pb2.MemberVisibility.OWNERS:
+    return not effective_ids.isdisjoint(owner_ids)
+  elif visibility == usergroup_pb2.MemberVisibility.MEMBERS:
+    return (not effective_ids.isdisjoint(member_ids) or
+            not effective_ids.isdisjoint(owner_ids))
+  else:
+    return True
+
+
+def IsBanned(user, user_view):
+  """Return True if this user is banned from using our site."""
+  if user is None:
+    return False  # Anyone is welcome to browse
+
+  if user.banned:
+    return True  # We checked the "Banned" checkbox for this user.
+
+  if user_view:
+    if user_view.domain in settings.banned_user_domains:
+      return True  # Some spammers create many accounts with the same domain.
+
+  if '+' in (user.email or ''):
+    # Spammers can make plus-addr Google accounts in unexpected domains.
+    return True
+
+  return False
+
+
+def CanBan(mr, services):
+  """Return True if the user is allowed to ban other users, site-wide."""
+  if mr.perms.HasPerm(ADMINISTER_SITE, None, None):
+    return True
+
+  owned, _, _ = services.project.GetUserRolesInAllProjects(mr.cnxn,
+      mr.auth.effective_ids)
+  return len(owned) > 0
+
+
+def CanExpungeUsers(mr):
+  """Return True is the user is allowed to delete user accounts."""
+  return mr.perms.HasPerm(ADMINISTER_SITE, None, None)
+
+
+def CanViewContributorList(mr, project):
+  """Return True if we should display the list project contributors.
+
+  This is used on the project summary page, when deciding to offer the
+  project People page link, and when generating autocomplete options
+  that include project members.
+
+  Args:
+    mr: commonly used info parsed from the request.
+    project: the Project we're interested in.
+
+  Returns:
+    True if we should display the project contributor list.
+  """
+  if not project:
+    return False  # We are not even in a project context.
+
+  if not project.only_owners_see_contributors:
+    return True  # Contributor list is not resticted.
+
+  # If it is hub-and-spoke, check for the perm that allows the user to
+  # view it anyway.
+  return mr.perms.HasPerm(
+      VIEW_CONTRIBUTOR_LIST, mr.auth.user_id, project)
+
+
+def ShouldCheckForAbandonment(mr):
+  """Return True if user should be warned before changing/deleting their role.
+
+  Args:
+    mr: common info parsed from the user's request.
+
+  Returns:
+    True if user should be warned before changing/deleting their role.
+  """
+  # Note: No need to warn admins because they won't lose access anyway.
+  if mr.perms.CanUsePerm(
+      ADMINISTER_SITE, mr.auth.effective_ids, mr.project, []):
+    return False
+
+  return mr.perms.CanUsePerm(
+      EDIT_PROJECT, mr.auth.effective_ids, mr.project, [])
+
+
+# For speed, we remember labels that we have already classified as being
+# restriction labels or not being restriction labels.  These sets are for
+# restrictions in general, not for any particular perm.
+_KNOWN_RESTRICTION_LABELS = set()
+_KNOWN_NON_RESTRICTION_LABELS = set()
+
+
+def IsRestrictLabel(label, perm=''):
+  """Returns True if a given label is a restriction label.
+
+  Args:
+    label: string for the label to examine.
+    perm: a permission that can be restricted (e.g. 'View' or 'Edit').
+        Defaults to '' to mean 'any'.
+
+  Returns:
+    True if a given label is a restriction label (of the specified perm)
+  """
+  if label in _KNOWN_NON_RESTRICTION_LABELS:
+    return False
+  if not perm and label in _KNOWN_RESTRICTION_LABELS:
+    return True
+
+  prefix = ('restrict-%s-' % perm.lower()) if perm else 'restrict-'
+  is_restrict = label.lower().startswith(prefix) and label.count('-') >= 2
+
+  if is_restrict:
+    _KNOWN_RESTRICTION_LABELS.add(label)
+  elif not perm:
+    _KNOWN_NON_RESTRICTION_LABELS.add(label)
+
+  return is_restrict
+
+
+def HasRestrictions(issue, perm=''):
+  """Return True if the issue has any restrictions (on the specified perm)."""
+  return (
+      any(IsRestrictLabel(lab, perm=perm) for lab in issue.labels) or
+      any(IsRestrictLabel(lab, perm=perm) for lab in issue.derived_labels))
+
+
+def GetRestrictions(issue, perm=''):
+  """Return a list of restriction labels on the given issue."""
+  if not issue:
+    return []
+
+  return [lab.lower() for lab in tracker_bizobj.GetLabels(issue)
+          if IsRestrictLabel(lab, perm=perm)]
+
+
+def CanViewIssue(
+    effective_ids, perms, project, issue, allow_viewing_deleted=False,
+    granted_perms=None):
+  """Checks if user has permission to view an artifact.
+
+  Args:
+    effective_ids: set of user IDs for the logged in user and any user
+        group memberships.  Should be an empty set for anon users.
+    perms: PermissionSet for the user.
+    project: Project PB for the project that contains this issue.
+    issue: Issue PB for the issue being viewed.
+    allow_viewing_deleted: True if the user should be allowed to view
+        deleted artifacts.
+    granted_perms: optional list of strings of permissions that the user is
+        granted only within the scope of one issue, e.g., by being named in
+        a user-type custom field that grants permissions.
+
+  Returns:
+    True iff the user can view the specified issue.
+  """
+  if issue.deleted and not allow_viewing_deleted:
+    return False
+
+  perms = UpdateIssuePermissions(
+      perms, project, issue, effective_ids, granted_perms=granted_perms)
+  return perms.HasPerm(VIEW, None, None)
+
+
+def CanEditIssue(effective_ids, perms, project, issue, granted_perms=None):
+  """Return True if a user can edit an issue.
+
+  Args:
+    effective_ids: set of user IDs for the logged in user and any user
+        group memberships.  Should be an empty set for anon users.
+    perms: PermissionSet for the user.
+    project: Project PB for the project that contains this issue.
+    issue: Issue PB for the issue being viewed.
+    granted_perms: optional list of strings of permissions that the user is
+        granted only within the scope of one issue, e.g., by being named in
+        a user-type custom field that grants permissions.
+
+  Returns:
+    True iff the user can edit the specified issue.
+  """
+  perms = UpdateIssuePermissions(
+      perms, project, issue, effective_ids, granted_perms=granted_perms)
+  return perms.HasPerm(EDIT_ISSUE, None, None)
+
+
+def CanCommentIssue(effective_ids, perms, project, issue, granted_perms=None):
+  """Return True if a user can comment on an issue."""
+
+  return perms.CanUsePerm(
+      ADD_ISSUE_COMMENT, effective_ids, project,
+      GetRestrictions(issue), granted_perms=granted_perms)
+
+
+def CanUpdateApprovalStatus(
+    effective_ids, perms, project, approver_ids, new_status):
+  """Return True if a user can change the approval status to the new status."""
+  if not effective_ids.isdisjoint(approver_ids):
+    return True # Approval approvers can always change the approval status
+
+  if new_status not in RESTRICTED_APPROVAL_STATUSES:
+    return True
+
+  return perms.CanUsePerm(EDIT_ISSUE_APPROVAL, effective_ids, project, [])
+
+
+def CanUpdateApprovers(effective_ids, perms, project, current_approver_ids):
+  """Return True if a user can edit the list of approvers for an approval."""
+  if not effective_ids.isdisjoint(current_approver_ids):
+    return True
+
+  return perms.CanUsePerm(EDIT_ISSUE_APPROVAL, effective_ids, project, [])
+
+
+def CanViewComponentDef(effective_ids, perms, project, component_def):
+  """Return True if a user can view the given component definition."""
+  if not effective_ids.isdisjoint(component_def.admin_ids):
+    return True  # Component admins can view that component.
+
+  # TODO(jrobbins): check restrictions on the component definition.
+  return perms.CanUsePerm(VIEW, effective_ids, project, [])
+
+
+def CanEditComponentDef(effective_ids, perms, project, component_def, config):
+  """Return True if a user can edit the given component definition."""
+  if not effective_ids.isdisjoint(component_def.admin_ids):
+    return True  # Component admins can edit that component.
+
+  # Check to see if user is admin of any parent component.
+  parent_components = tracker_bizobj.FindAncestorComponents(
+      config, component_def)
+  for parent in parent_components:
+    if not effective_ids.isdisjoint(parent.admin_ids):
+      return True
+
+  return perms.CanUsePerm(EDIT_PROJECT, effective_ids, project, [])
+
+
+def CanViewFieldDef(effective_ids, perms, project, field_def):
+  """Return True if a user can view the given field definition."""
+  if not effective_ids.isdisjoint(field_def.admin_ids):
+    return True  # Field admins can view that field.
+
+  # TODO(jrobbins): check restrictions on the field definition.
+  return perms.CanUsePerm(VIEW, effective_ids, project, [])
+
+
+def CanEditFieldDef(effective_ids, perms, project, field_def):
+  """Return True if a user can edit the given field definition."""
+  if not effective_ids.isdisjoint(field_def.admin_ids):
+    return True  # Field admins can edit that field.
+
+  return perms.CanUsePerm(EDIT_PROJECT, effective_ids, project, [])
+
+
+def CanEditValueForFieldDef(effective_ids, perms, project, field_def):
+  """Return True if a user can edit the given field definition value.
+     This method does not check that a user can edit the project issues."""
+  if not effective_ids:
+    return False
+  if not field_def.is_restricted_field:
+    return True
+  if not effective_ids.isdisjoint(field_def.editor_ids):
+    return True
+  return CanEditFieldDef(effective_ids, perms, project, field_def)
+
+
+def CanViewTemplate(effective_ids, perms, project, template):
+  """Return True if a user can view the given issue template."""
+  if not effective_ids.isdisjoint(template.admin_ids):
+    return True  # template admins can view that template.
+
+  # Members-only templates are only shown to members, other templates are
+  # shown to any user that is generally allowed to view project content.
+  if template.members_only:
+    return framework_bizobj.UserIsInProject(project, effective_ids)
+  else:
+    return perms.CanUsePerm(VIEW, effective_ids, project, [])
+
+
+def CanEditTemplate(effective_ids, perms, project, template):
+  """Return True if a user can edit the given field definition."""
+  if not effective_ids.isdisjoint(template.admin_ids):
+    return True  # Template admins can edit that template.
+
+  return perms.CanUsePerm(EDIT_PROJECT, effective_ids, project, [])
+
+
+def CanViewHotlist(effective_ids, perms, hotlist):
+  """Return True if a user can view the given hotlist."""
+  if not hotlist.is_private or perms.HasPerm(ADMINISTER_SITE, None, None):
+    return True
+
+  return any([user_id in (hotlist.owner_ids + hotlist.editor_ids)
+              for user_id in effective_ids])
+
+
+def CanEditHotlist(effective_ids, perms, hotlist):
+  """Return True if a user is editor(add/remove issues and change rankings)."""
+  return perms.HasPerm(ADMINISTER_SITE, None, None) or any(
+      [user_id in (hotlist.owner_ids + hotlist.editor_ids)
+       for user_id in effective_ids])
+
+
+def CanAdministerHotlist(effective_ids, perms, hotlist):
+  """Return True if user is owner(add/remove members, edit/delete hotlist)."""
+  return perms.HasPerm(ADMINISTER_SITE, None, None) or any(
+      [user_id in hotlist.owner_ids for user_id in effective_ids])
+
+
+def CanCreateHotlist(perms):
+  """Return True if the given user may create a hotlist.
+
+  Args:
+    perms: Permissionset for the current user.
+
+  Returns:
+    True if the user should be allowed to create a hotlist.
+  """
+  if (settings.hotlist_creation_restriction ==
+      site_pb2.UserTypeRestriction.ANYONE):
+    return perms.HasPerm(CREATE_HOTLIST, None, None)
+
+  if (settings.hotlist_creation_restriction ==
+      site_pb2.UserTypeRestriction.ADMIN_ONLY):
+    return perms.HasPerm(ADMINISTER_SITE, None, None)
+
+
+class Error(Exception):
+  """Base class for errors from this module."""
+
+
+class PermissionException(Error):
+  """The user is not authorized to make the current request."""
+
+
+class BannedUserException(Error):
+  """The user has been banned from using our service."""