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."""