← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/git-honour-access-tokens into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/git-honour-access-tokens into lp:launchpad with lp:~cjwatson/launchpad/git-issue-access-tokens as a prerequisite.

Commit message:
Make the Git XML-RPC API honour user macaroons.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1824399 in Launchpad itself: "Add Git HTTPS push tokens for snapcraft experiment"
  https://bugs.launchpad.net/launchpad/+bug/1824399

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/git-honour-access-tokens/+merge/366053
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/git-honour-access-tokens into lp:launchpad.
=== modified file 'lib/lp/code/model/codeimportjob.py'
--- lib/lp/code/model/codeimportjob.py	2019-04-15 11:42:00 +0000
+++ lib/lp/code/model/codeimportjob.py	2019-04-15 11:42:00 +0000
@@ -456,6 +456,10 @@
 
     def verifyPrimaryCaveat(self, caveat_value, context, **kwargs):
         """See `MacaroonIssuerBase`."""
+        # Code import jobs only support free-floating macaroons for Git
+        # authentication, not ones bound to a user.
+        if kwargs.get("user"):
+            return False
         if context is None:
             # We're only verifying that the macaroon could be valid for some
             # context.

=== modified file 'lib/lp/code/xmlrpc/git.py'
--- lib/lp/code/xmlrpc/git.py	2019-04-15 11:42:00 +0000
+++ lib/lp/code/xmlrpc/git.py	2019-04-15 11:42:00 +0000
@@ -57,7 +57,10 @@
     InvalidName,
     NoSuchSourcePackageName,
     )
-from lp.registry.interfaces.person import NoSuchPerson
+from lp.registry.interfaces.person import (
+    IPersonSet,
+    NoSuchPerson,
+    )
 from lp.registry.interfaces.product import (
     InvalidProductName,
     NoSuchProduct,
@@ -79,7 +82,7 @@
         super(GitAPI, self).__init__(*args, **kwargs)
         self.repository_set = getUtility(IGitRepositorySet)
 
-    def _verifyMacaroon(self, macaroon_raw, repository=None):
+    def _verifyMacaroon(self, macaroon_raw, repository=None, user=None):
         try:
             macaroon = Macaroon.deserialize(macaroon_raw)
         except Exception:
@@ -89,7 +92,7 @@
         except ComponentLookupError:
             return False
         return issuer.verifyMacaroon(
-            macaroon, repository, require_context=False)
+            macaroon, repository, require_context=False, user=user)
 
     def _performLookup(self, requester, path, auth_params):
         repository, extra_path = getUtility(IGitLookup).getByPath(path)
@@ -97,17 +100,34 @@
             return None
         macaroon_raw = auth_params.get("macaroon")
         naked_repository = removeSecurityProxy(repository)
-        if (macaroon_raw is not None and
-                self._verifyMacaroon(macaroon_raw, naked_repository)):
-            # The authentication parameters specifically grant access to
-            # this repository, so we can bypass other checks.
-            # For the time being, this only works for code imports.
-            assert (
-                naked_repository.repository_type == GitRepositoryType.IMPORTED)
-            hosting_path = naked_repository.getInternalPath()
-            writable = True
-            private = naked_repository.private
-        else:
+        writable = None
+
+        if macaroon_raw is not None:
+            if not self._verifyMacaroon(
+                    macaroon_raw, naked_repository, user=requester):
+                # Macaroon authentication failed.  Don't fall back to the
+                # requester's permissions, since macaroons typically have
+                # additional constraints.  Instead, return "permission
+                # denied" for visible repositories, and deny the existence
+                # of invisible ones.
+                if check_permission("launchpad.View", repository):
+                    raise faults.PermissionDenied()
+                else:
+                    return None
+            # We have a macaroon that grants a subset of the requester's
+            # permissions.  If the requester is None (the code import case),
+            # then we ordinarily map that to the anonymous principal, but
+            # that doesn't have any useful write access; in this case we're
+            # really starting from a principal with all permissions and
+            # constraining that, so just bypass other checks and say what we
+            # mean directly.  If the requester is not None, then fall
+            # through and do ordinary permission checks for them.
+            if requester is None:
+                hosting_path = naked_repository.getInternalPath()
+                writable = True
+                private = naked_repository.private
+
+        if writable is None:
             try:
                 hosting_path = repository.getInternalPath()
             except Unauthorized:
@@ -123,6 +143,7 @@
                 if not grants.is_empty():
                     writable = True
             private = repository.private
+
         return {
             "path": hosting_path,
             "writable": writable,
@@ -315,12 +336,15 @@
         getUtility(IGitRefScanJobSource).create(
             removeSecurityProxy(repository))
 
+    @return_fault
     def authenticateWithPassword(self, username, password):
         """See `IGitAPI`."""
-        # XXX cjwatson 2016-10-06: We only support free-floating macaroons
-        # at the moment, not ones bound to a user.
-        if not username and self._verifyMacaroon(password):
-            return {"macaroon": password}
+        user = getUtility(IPersonSet).getByName(username) if username else None
+        if self._verifyMacaroon(password, user=user):
+            params = {"macaroon": password}
+            if user is not None:
+                params["uid"] = user.id
+            return params
         else:
             # Only macaroons are supported for password authentication.
             return faults.Unauthorized()
@@ -347,13 +371,19 @@
             raise faults.GitRepositoryNotFound(translated_path)
 
         macaroon_raw = auth_params.get("macaroon")
-        if (macaroon_raw is not None and
-                self._verifyMacaroon(macaroon_raw, repository)):
-            # The authentication parameters grant access as an anonymous
-            # repository owner.
-            # For the time being, this only works for code imports.
-            assert repository.repository_type == GitRepositoryType.IMPORTED
-            requester = GitGranteeType.REPOSITORY_OWNER
+        if macaroon_raw is not None:
+            if not self._verifyMacaroon(
+                    macaroon_raw, repository, user=requester):
+                # Macaroon authentication failed.  Don't fall back to the
+                # requester's permissions, since macaroons typically have
+                # additional constraints.
+                return [
+                    (xmlrpc_client.Binary(ref_path.data), [])
+                    for ref_path in ref_paths]
+            # If we don't have a requester, then the authentication
+            # parameters grant access as an anonymous repository owner.
+            if requester is None:
+                requester = GitGranteeType.REPOSITORY_OWNER
 
         if all(isinstance(ref_path, xmlrpc_client.Binary)
                for ref_path in ref_paths):

=== modified file 'lib/lp/code/xmlrpc/tests/test_git.py'
--- lib/lp/code/xmlrpc/tests/test_git.py	2019-04-15 11:42:00 +0000
+++ lib/lp/code/xmlrpc/tests/test_git.py	2019-04-15 11:42:00 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for the internal Git API."""
@@ -90,13 +90,15 @@
 
     def assertUnauthorized(self, requester, path,
                            message="Authorisation required.",
-                           permission="read", can_authenticate=False):
+                           permission="read", can_authenticate=False,
+                           macaroon_raw=None):
         """Assert that looking at the given path returns Unauthorized."""
         if requester is not None:
             requester = requester.id
-        fault = self.git_api.translatePath(
-            path, permission,
-            {"uid": requester, "can-authenticate": can_authenticate})
+        auth_params = {"uid": requester, "can-authenticate": can_authenticate}
+        if macaroon_raw is not None:
+            auth_params["macaroon"] = macaroon_raw
+        fault = self.git_api.translatePath(path, permission, auth_params)
         self.assertEqual(faults.Unauthorized(message), fault)
 
     def assertNotFound(self, requester, path, message, permission="read",
@@ -1039,6 +1041,46 @@
         self.assertGitRepositoryNotFound(
             None, path, permission="write", macaroon_raw="nonsense")
 
+    def test_translatePath_user_macaroon(self):
+        # A user with a suitable macaroon can write to the corresponding
+        # repository, but not others, even if they own them.
+        self.pushConfig(
+            "launchpad", internal_macaroon_secret_key="some-secret")
+        requester = self.factory.makePerson()
+        repositories = [
+            self.factory.makeGitRepository(owner=requester) for _ in range(2)]
+        repositories.append(self.factory.makeGitRepository(
+            owner=requester, information_type=InformationType.PRIVATESECURITY))
+        issuer = getUtility(IMacaroonIssuer, "git-repository")
+        with person_logged_in(requester):
+            macaroons = [
+                removeSecurityProxy(issuer).issueMacaroon(repository)
+                for repository in repositories]
+            paths = [
+                u"/%s" % repository.unique_name for repository in repositories]
+        for i, repository in enumerate(repositories):
+            for j, macaroon in enumerate(macaroons):
+                login(ANONYMOUS)
+                if i == j:
+                    self.assertTranslates(
+                        requester, paths[i], repository, True,
+                        permission="write", macaroon_raw=macaroon.serialize(),
+                        private=(i == 2))
+                else:
+                    self.assertPermissionDenied(
+                        requester, paths[i], permission="write",
+                        macaroon_raw=macaroon.serialize())
+            login(ANONYMOUS)
+            self.assertPermissionDenied(
+                requester, paths[i], permission="write",
+                macaroon_raw=Macaroon(
+                    location=config.vhost.mainsite.hostname,
+                    identifier="another", key="another-secret").serialize())
+            login(ANONYMOUS)
+            self.assertPermissionDenied(
+                requester, paths[i], permission="write",
+                macaroon_raw="nonsense")
+
     def test_notify(self):
         # The notify call creates a GitRefScanJob.
         repository = self.factory.makeGitRepository()
@@ -1092,6 +1134,37 @@
             self.git_api.authenticateWithPassword("", "nonsense"),
             faults.Unauthorized)
 
+    def test_authenticateWithPassword_user_macaroon(self):
+        # A user with a suitable macaroon can authenticate using it, in
+        # which case we return both the macaroon and the uid for use by
+        # later calls.
+        self.pushConfig(
+            "launchpad", internal_macaroon_secret_key="some-secret")
+        requester = self.factory.makePerson()
+        issuer = getUtility(IMacaroonIssuer, "git-repository")
+        with person_logged_in(requester):
+            macaroon = removeSecurityProxy(issuer).issueMacaroon(
+                self.factory.makeGitRepository(owner=requester))
+        self.assertEqual(
+            {"macaroon": macaroon.serialize(), "uid": requester.id},
+            self.git_api.authenticateWithPassword(
+                requester.name, macaroon.serialize()))
+        self.assertIsInstance(
+            self.git_api.authenticateWithPassword("", macaroon.serialize()),
+            faults.Unauthorized)
+        self.assertIsInstance(
+            self.git_api.authenticateWithPassword(
+                "nonexistent", macaroon.serialize()),
+            faults.Unauthorized)
+        other_macaroon = Macaroon(identifier="another", key="another-secret")
+        self.assertIsInstance(
+            self.git_api.authenticateWithPassword(
+                requester.name, other_macaroon.serialize()),
+            faults.Unauthorized)
+        self.assertIsInstance(
+            self.git_api.authenticateWithPassword(requester.name, "nonsense"),
+            faults.Unauthorized)
+
     def test_checkRefPermissions_code_import(self):
         # A code import worker with a suitable macaroon has repository owner
         # privileges on a repository associated with a running code import
@@ -1177,6 +1250,53 @@
             None, repository, [ref_path], {ref_path: []},
             macaroon_raw="nonsense")
 
+    def test_checkRefPermissions_user_macaroon(self):
+        # A user with a suitable macaroon has their ordinary privileges on
+        # the corresponding repository, but not others, even if they own
+        # them.
+        self.pushConfig(
+            "launchpad", internal_macaroon_secret_key="some-secret")
+        requester = self.factory.makePerson()
+        repositories = [
+            self.factory.makeGitRepository(owner=requester) for _ in range(2)]
+        repositories.append(self.factory.makeGitRepository(
+            owner=requester, information_type=InformationType.PRIVATESECURITY))
+        ref_path = b"refs/heads/master"
+        issuer = getUtility(IMacaroonIssuer, "git-repository")
+        with person_logged_in(requester):
+            macaroons = [
+                removeSecurityProxy(issuer).issueMacaroon(repository)
+                for repository in repositories]
+        for i, repository in enumerate(repositories):
+            for j, macaroon in enumerate(macaroons):
+                login(ANONYMOUS)
+                if i == j:
+                    expected_permissions = ["create", "push", "force_push"]
+                else:
+                    expected_permissions = []
+                self.assertHasRefPermissions(
+                    requester, repository, [ref_path],
+                    {ref_path: expected_permissions},
+                    macaroon_raw=macaroon.serialize())
+            login(ANONYMOUS)
+            self.assertHasRefPermissions(
+                None, repository, [ref_path], {ref_path: []},
+                macaroon_raw=macaroon.serialize())
+            login(ANONYMOUS)
+            self.assertHasRefPermissions(
+                self.factory.makePerson(), repository, [ref_path],
+                {ref_path: []}, macaroon_raw=macaroon.serialize())
+            login(ANONYMOUS)
+            self.assertHasRefPermissions(
+                requester, repository, [ref_path], {ref_path: []},
+                macaroon_raw=Macaroon(
+                    location=config.vhost.mainsite.hostname,
+                    identifier="another", key="another-secret").serialize())
+            login(ANONYMOUS)
+            self.assertHasRefPermissions(
+                requester, repository, [ref_path], {ref_path: []},
+                macaroon_raw="nonsense")
+
 
 class TestGitAPISecurity(TestGitAPIMixin, TestCaseWithFactory):
     """Slow tests for `IGitAPI`.


Follow ups