launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #23034
[Merge] lp:~cjwatson/launchpad/git-permissions-check-api into lp:launchpad
Colin Watson has proposed merging lp:~cjwatson/launchpad/git-permissions-check-api into lp:launchpad.
Commit message:
Add webservice APIs to allow checking a user's effective permissions on Git refs.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
Related bugs:
Bug #1517559 in Launchpad itself: "git fine-grained permissions"
https://bugs.launchpad.net/launchpad/+bug/1517559
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/git-permissions-check-api/+merge/357842
I ended up pushing most of the guts of GitAPI._checkRefPermissions down to GitRepository, since we need almost all of the same logic except for looking up translated paths, handling authentication parameters, and the slightly different output encoding.
--
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/git-permissions-check-api into lp:launchpad.
=== modified file 'lib/lp/code/interfaces/gitref.py'
--- lib/lp/code/interfaces/gitref.py 2018-10-22 13:10:21 +0000
+++ lib/lp/code/interfaces/gitref.py 2018-10-25 19:12:27 +0000
@@ -433,6 +433,19 @@
Other grants may apply via wildcard rules.
"""
+ @operation_parameters(
+ person=Reference(title=_("Person to check"), schema=IPerson))
+ @export_read_operation()
+ @operation_for_version("devel")
+ def checkPermissions(person):
+ """Check a person's permissions on this reference.
+
+ :param person: An `IPerson` to check.
+ :return: A list of zero or more of "create", "push", and
+ "force-push", indicating the requested person's effective
+ permissions on this reference.
+ """
+
class IGitRef(IGitRefView, IGitRefEdit):
"""A reference in a Git repository."""
=== modified file 'lib/lp/code/interfaces/gitrepository.py'
--- lib/lp/code/interfaces/gitrepository.py 2018-10-23 16:15:37 +0000
+++ lib/lp/code/interfaces/gitrepository.py 2018-10-25 19:12:27 +0000
@@ -25,6 +25,7 @@
export_as_webservice_collection,
export_as_webservice_entry,
export_destructor_operation,
+ export_operation_as,
export_read_operation,
export_write_operation,
exported,
@@ -809,6 +810,35 @@
def setRules(rules, user):
"""Set the access rules for this repository."""
+ def checkRefPermissions(person, ref_paths):
+ """Check a person's permissions on some references in this repository.
+
+ :param person: An `IPerson` to check, or
+ `GitGranteeType.REPOSITORY_OWNER` to check an anonymous
+ repository owner.
+ :param ref_paths: An iterable of reference paths.
+ :return: A dict mapping reference paths to sets of
+ `GitPermissionType`, corresponding to the requested person's
+ effective permissions on each of the requested references.
+ """
+
+ @operation_parameters(
+ person=Reference(title=_("Person to check"), schema=IPerson),
+ paths=List(title=_("Reference paths"), value_type=TextLine()))
+ @export_operation_as("checkRefPermissions")
+ @export_read_operation()
+ @operation_for_version("devel")
+ def api_checkRefPermissions(person, paths):
+ """Check a person's permissions on some references in this repository.
+
+ :param person: An `IPerson` to check.
+ :param paths: An iterable of reference paths.
+ :return: A dict mapping reference paths to lists of zero or more of
+ "create", "push", and "force-push", indicating the requested
+ person's effective permissions on each of the requested
+ references.
+ """
+
@export_read_operation()
@operation_for_version("devel")
def canBeDeleted():
=== modified file 'lib/lp/code/model/gitref.py'
--- lib/lp/code/model/gitref.py 2018-10-22 12:43:55 +0000
+++ lib/lp/code/model/gitref.py 2018-10-25 19:12:27 +0000
@@ -65,6 +65,7 @@
IGitRef,
IGitRefRemoteSet,
)
+from lp.code.interfaces.gitrule import describe_git_permissions
from lp.code.model.branchmergeproposal import (
BranchMergeProposal,
BranchMergeProposalGetter,
@@ -441,6 +442,12 @@
rule = self.repository.addRule(self.path, user)
rule.setGrants(grants, user)
+ def checkPermissions(self, person):
+ """See `IGitRef`."""
+ return describe_git_permissions(
+ self.repository.checkRefPermissions(
+ person, [self.path])[self.path])
+
@implementer(IGitRef)
class GitRef(StormBase, GitRefMixin):
@@ -823,6 +830,7 @@
return []
setGrants = _unimplemented
+ checkPermissions = _unimplemented
def __eq__(self, other):
return (
=== modified file 'lib/lp/code/model/gitrepository.py'
--- lib/lp/code/model/gitrepository.py 2018-10-22 16:24:46 +0000
+++ lib/lp/code/model/gitrepository.py 2018-10-25 19:12:27 +0000
@@ -9,9 +9,13 @@
'parse_git_commits',
]
-from collections import OrderedDict
+from collections import (
+ defaultdict,
+ OrderedDict,
+ )
from datetime import datetime
import email
+from fnmatch import fnmatch
from functools import partial
from itertools import (
chain,
@@ -48,7 +52,10 @@
Reference,
Unicode,
)
-from storm.store import Store
+from storm.store import (
+ EmptyResultSet,
+ Store,
+ )
from zope.component import getUtility
from zope.event import notify
from zope.interface import (
@@ -81,6 +88,7 @@
BranchMergeProposalStatus,
GitGranteeType,
GitObjectType,
+ GitPermissionType,
GitRepositoryType,
)
from lp.code.errors import (
@@ -114,6 +122,7 @@
IGitRepositorySet,
user_has_special_git_repository_access,
)
+from lp.code.interfaces.gitrule import describe_git_permissions
from lp.code.interfaces.revision import IRevisionSet
from lp.code.mail.branch import send_git_repository_modified_notifications
from lp.code.model.branchmergeproposal import BranchMergeProposal
@@ -1279,6 +1288,68 @@
"(got %s instead)" %
(requested_rule_order, observed_rule_order))
+ def checkRefPermissions(self, person, ref_paths):
+ """See `IGitRepository`."""
+ result = {}
+
+ rules = list(self.rules)
+ grants_for_user = defaultdict(list)
+ grants = EmptyResultSet()
+ is_owner = False
+ if IPerson.providedBy(person):
+ grants = grants.union(self.findRuleGrantsByGrantee(person))
+ if person.inTeam(self.owner):
+ is_owner = True
+ elif person == GitGranteeType.REPOSITORY_OWNER:
+ is_owner = True
+ if is_owner:
+ grants = grants.union(
+ self.findRuleGrantsByGrantee(GitGranteeType.REPOSITORY_OWNER))
+ for grant in grants:
+ grants_for_user[grant.rule].append(grant)
+
+ for ref_path in ref_paths:
+ matching_rules = [
+ rule for rule in rules if fnmatch(ref_path, rule.ref_pattern)]
+ if is_owner and not matching_rules:
+ # If there are no matching rules, then the repository owner
+ # can do anything.
+ result[ref_path] = {
+ GitPermissionType.CAN_CREATE, GitPermissionType.CAN_PUSH,
+ GitPermissionType.CAN_FORCE_PUSH,
+ }
+ continue
+
+ seen_grantees = set()
+ union_permissions = set()
+ for rule in matching_rules:
+ for grant in grants_for_user[rule]:
+ if (grant.grantee, grant.grantee_type) in seen_grantees:
+ continue
+ union_permissions.update(grant.permissions)
+ seen_grantees.add((grant.grantee, grant.grantee_type))
+
+ owner_type = (None, GitGranteeType.REPOSITORY_OWNER)
+ if is_owner and owner_type not in seen_grantees:
+ union_permissions.update(
+ {GitPermissionType.CAN_CREATE, GitPermissionType.CAN_PUSH})
+
+ # Permission to force-push implies permission to push.
+ if GitPermissionType.CAN_FORCE_PUSH in union_permissions:
+ union_permissions.add(GitPermissionType.CAN_PUSH)
+
+ result[ref_path] = union_permissions
+
+ return result
+
+ def api_checkRefPermissions(self, person, paths):
+ """See `IGitRepository`."""
+ return {
+ path: describe_git_permissions(permissions)
+ for path, permissions in self.checkRefPermissions(
+ person, paths).items()
+ }
+
def getActivity(self, changed_after=None):
"""See `IGitRepository`."""
clauses = [GitActivity.repository_id == self.id]
=== modified file 'lib/lp/code/model/tests/test_gitref.py'
--- lib/lp/code/model/tests/test_gitref.py 2018-10-16 15:29:37 +0000
+++ lib/lp/code/model/tests/test_gitref.py 2018-10-25 19:12:27 +0000
@@ -875,3 +875,30 @@
can_push=Is(True),
can_force_push=Is(False)))),
]))
+
+ def test_checkPermissions(self):
+ [ref] = self.factory.makeGitRefs()
+ owner = ref.owner
+ grantees = [self.factory.makePerson() for _ in range(2)]
+ self.factory.makeGitRuleGrant(
+ repository=ref.repository, ref_pattern=ref.path,
+ grantee=grantees[0], can_create=True)
+ self.factory.makeGitRuleGrant(
+ repository=ref.repository, ref_pattern="*",
+ grantee=grantees[1], can_force_push=True)
+ with person_logged_in(owner):
+ ref_url = api_url(ref)
+ owner_url = api_url(owner)
+ grantee_urls = [api_url(grantee) for grantee in grantees]
+ webservice = webservice_for_person(
+ owner, permission=OAuthPermission.WRITE_PUBLIC)
+ webservice.default_api_version = "devel"
+ response = webservice.named_get(
+ ref_url, "checkPermissions", person=owner_url)
+ self.assertEqual(["create", "push"], json.loads(response.body))
+ response = webservice.named_get(
+ ref_url, "checkPermissions", person=grantee_urls[0])
+ self.assertEqual(["create"], json.loads(response.body))
+ response = webservice.named_get(
+ ref_url, "checkPermissions", person=grantee_urls[1])
+ self.assertEqual(["push", "force-push"], json.loads(response.body))
=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
--- lib/lp/code/model/tests/test_gitrepository.py 2018-10-22 16:24:46 +0000
+++ lib/lp/code/model/tests/test_gitrepository.py 2018-10-25 19:12:27 +0000
@@ -3633,3 +3633,48 @@
can_push=Is(True),
can_force_push=Is(False)))),
]))
+
+ def test_checkRefPermissions(self):
+ repository = self.factory.makeGitRepository()
+ owner = repository.owner
+ grantees = [self.factory.makePerson() for _ in range(2)]
+ master_rule = self.factory.makeGitRule(
+ repository=repository, ref_pattern="refs/heads/master")
+ self.factory.makeGitRuleGrant(
+ rule=master_rule, grantee=grantees[0], can_create=True)
+ self.factory.makeGitRuleGrant(
+ rule=master_rule, grantee=grantees[1], can_push=True)
+ self.factory.makeGitRuleGrant(
+ repository=repository, ref_pattern="refs/heads/*",
+ grantee=grantees[1], can_force_push=True)
+ with person_logged_in(owner):
+ repository_url = api_url(repository)
+ owner_url = api_url(owner)
+ grantee_urls = [api_url(grantee) for grantee in grantees]
+ webservice = webservice_for_person(
+ owner, permission=OAuthPermission.WRITE_PUBLIC)
+ webservice.default_api_version = "devel"
+ response = webservice.named_get(
+ repository_url, "checkRefPermissions", person=owner_url,
+ paths=["refs/heads/master", "refs/heads/next", "refs/other"])
+ self.assertThat(json.loads(response.body), MatchesDict({
+ "refs/heads/master": Equals(["create", "push"]),
+ "refs/heads/next": Equals(["create", "push"]),
+ "refs/other": Equals(["create", "push", "force-push"]),
+ }))
+ response = webservice.named_get(
+ repository_url, "checkRefPermissions", person=grantee_urls[0],
+ paths=["refs/heads/master", "refs/heads/next", "refs/other"])
+ self.assertThat(json.loads(response.body), MatchesDict({
+ "refs/heads/master": Equals(["create"]),
+ "refs/heads/next": Equals([]),
+ "refs/other": Equals([]),
+ }))
+ response = webservice.named_get(
+ repository_url, "checkRefPermissions", person=grantee_urls[1],
+ paths=["refs/heads/master", "refs/heads/next", "refs/other"])
+ self.assertThat(json.loads(response.body), MatchesDict({
+ "refs/heads/master": Equals(["push"]),
+ "refs/heads/next": Equals(["push", "force-push"]),
+ "refs/other": Equals([]),
+ }))
=== modified file 'lib/lp/code/xmlrpc/git.py'
--- lib/lp/code/xmlrpc/git.py 2018-10-24 00:42:39 +0000
+++ lib/lp/code/xmlrpc/git.py 2018-10-25 19:12:27 +0000
@@ -8,8 +8,6 @@
'GitAPI',
]
-from collections import defaultdict
-from fnmatch import fnmatch
import sys
from pymacaroons import Macaroon
@@ -350,31 +348,13 @@
permissions.append('force_push')
return permissions
- def _buildPermissions(self, grant):
- """Build a set of the available permissions from a GitRuleGrant"""
- permissions = set(grant.permissions)
- if GitPermissionType.CAN_FORCE_PUSH in permissions:
- # Permission to force-push implies permission to push.
- permissions.add(GitPermissionType.CAN_PUSH)
- return permissions
-
- def _findMatchingRules(self, repository, ref_path):
- """Find all matching rules for a given ref path"""
- matching_rules = []
- for rule in repository.rules:
- if fnmatch(ref_path, rule.ref_pattern):
- matching_rules.append(rule)
- return matching_rules
-
def _checkRefPermissions(self, requester, translated_path, ref_paths,
auth_params):
if requester == LAUNCHPAD_ANONYMOUS:
requester = None
repository = removeSecurityProxy(
getUtility(IGitLookup).getByHostingPath(translated_path))
- result = {}
- grants_for_user = defaultdict(list)
macaroon_raw = auth_params.get("macaroon")
if (macaroon_raw is not None and
self._verifyMacaroon(macaroon_raw, repository)):
@@ -382,44 +362,13 @@
# repository owner.
# For the time being, this only works for code imports.
assert repository.repository_type == GitRepositoryType.IMPORTED
- is_owner = True
- grants = repository.findRuleGrantsByGrantee(
- GitGranteeType.REPOSITORY_OWNER)
- elif requester is None:
- is_owner = False
- grants = []
- else:
- is_owner = requester.inTeam(repository.owner)
- grants = repository.findRuleGrantsByGrantee(requester)
- if is_owner:
- owner_grants = repository.findRuleGrantsByGrantee(
- GitGranteeType.REPOSITORY_OWNER)
- grants = grants.union(owner_grants)
- for grant in grants:
- grants_for_user[grant.rule].append(grant)
-
- for ref in ref_paths:
- matching_rules = self._findMatchingRules(repository, ref)
- if is_owner and not matching_rules:
- result[ref] = ['create', 'push', 'force_push']
- continue
- seen_grantees = set()
- union_permissions = set()
- for rule in matching_rules:
- for grant in grants_for_user[rule]:
- if (grant.grantee, grant.grantee_type) in seen_grantees:
- continue
- permissions = self._buildPermissions(grant)
- union_permissions.update(permissions)
- seen_grantees.add((grant.grantee, grant.grantee_type))
-
- owner_type = (None, GitGranteeType.REPOSITORY_OWNER)
- if is_owner and owner_type not in seen_grantees:
- union_permissions.update(
- [GitPermissionType.CAN_CREATE, GitPermissionType.CAN_PUSH])
-
- result[ref] = self._renderPermissions(union_permissions)
- return result
+ requester = GitGranteeType.REPOSITORY_OWNER
+
+ return {
+ ref_path: self._renderPermissions(permissions)
+ for ref_path, permissions in repository.checkRefPermissions(
+ requester, ref_paths).items()
+ }
def checkRefPermissions(self, translated_path, ref_paths, auth_params):
""" See `IGitAPI`"""
Follow ups