← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/snap-build-macaroon into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-build-macaroon into lp:launchpad with lp:~cjwatson/launchpad/librarian-accept-macaroon as a prerequisite.

Commit message:
Add a snap-build macaroon issuer.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1639975 in Launchpad itself: "support for building private snaps"
  https://bugs.launchpad.net/launchpad/+bug/1639975

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/snap-build-macaroon/+merge/364333

I factored out some common code into a base class, since there are now three rather similar implementations.

Nothing uses this yet, but I'm planning for SnapBuildBehaviour to do so via an extension to the authserver.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-build-macaroon into lp:launchpad.
=== modified file 'lib/lp/code/model/codeimportjob.py'
--- lib/lp/code/model/codeimportjob.py	2019-03-12 17:19:57 +0000
+++ lib/lp/code/model/codeimportjob.py	2019-03-12 17:19:58 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Database classes for the CodeImportJob table."""
@@ -12,10 +12,6 @@
 
 import datetime
 
-from pymacaroons import (
-    Macaroon,
-    Verifier,
-    )
 from sqlobject import (
     ForeignKey,
     IntCol,
@@ -59,6 +55,7 @@
     sqlvalues,
     )
 from lp.services.macaroons.interfaces import IMacaroonIssuer
+from lp.services.macaroons.model import MacaroonIssuerBase
 
 
 @implementer(ICodeImportJob)
@@ -409,7 +406,9 @@
 
 
 @implementer(IMacaroonIssuer)
-class CodeImportJobMacaroonIssuer:
+class CodeImportJobMacaroonIssuer(MacaroonIssuerBase):
+
+    identifier = "code-import-job"
 
     @property
     def _root_secret(self):
@@ -426,35 +425,17 @@
     def issueMacaroon(self, context):
         """See `IMacaroonIssuer`."""
         assert context.code_import.git_repository is not None
-        macaroon = Macaroon(
-            location=config.vhost.mainsite.hostname,
-            identifier="code-import-job", key=self._root_secret)
-        macaroon.add_first_party_caveat("lp.code-import-job %s" % context.id)
-        return macaroon
+        return super(CodeImportJobMacaroonIssuer, self).issueMacaroon(
+            context.id)
 
-    def checkMacaroonIssuer(self, macaroon):
-        """See `IMacaroonIssuer`."""
-        if macaroon.location != config.vhost.mainsite.hostname:
-            return False
-        try:
-            verifier = Verifier()
-            verifier.satisfy_general(
-                lambda caveat: caveat.startswith("lp.code-import-job "))
-            return verifier.verify(macaroon, self._root_secret)
-        except Exception:
-            return False
+    def verifyCaveat(self, caveat_text, context):
+        """See `MacaroonIssuerBase`."""
+        return caveat_text == str(context.id)
 
     def verifyMacaroon(self, macaroon, context):
         """See `IMacaroonIssuer`."""
-        if not ICodeImportJob.providedBy(context):
-            return False
-        if not self.checkMacaroonIssuer(macaroon):
-            return False
-        try:
-            verifier = Verifier()
-            verifier.satisfy_exact("lp.code-import-job %s" % context.id)
-            return (
-                verifier.verify(macaroon, self._root_secret) and
-                context.state == CodeImportJobState.RUNNING)
-        except Exception:
-            return False
+        if (not ICodeImportJob.providedBy(context) or
+                context.state != CodeImportJobState.RUNNING):
+            return False
+        return super(CodeImportJobMacaroonIssuer, self).verifyMacaroon(
+            macaroon, context)

=== added file 'lib/lp/services/macaroons/model.py'
--- lib/lp/services/macaroons/model.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/macaroons/model.py	2019-03-12 17:19:58 +0000
@@ -0,0 +1,100 @@
+# Copyright 2016-2019 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Policies for issuing and verifying macaroons."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+    "MacaroonIssuerBase",
+    ]
+
+from pymacaroons import (
+    Macaroon,
+    Verifier,
+    )
+
+from lp.services.config import config
+
+
+class MacaroonIssuerBase:
+    """See `IMacaroonIssuer`."""
+
+    @property
+    def identifier(self):
+        """An identifying name for this issuer."""
+        raise NotImplementedError
+
+    @property
+    def prefix(self):
+        """Prefix for caveats issued by this issuer."""
+        return "lp.%s " % self.identifier
+
+    @property
+    def _root_secret(self):
+        secret = config.launchpad.internal_macaroon_secret_key
+        if not secret:
+            raise RuntimeError(
+                "launchpad.internal_macaroon_secret_key not configured.")
+        return secret
+
+    def issueMacaroon(self, context):
+        """See `IMacaroonIssuer`.
+
+        Concrete implementations should normally wrap this with some
+        additional checks of and/or changes to the context.
+        """
+        macaroon = Macaroon(
+            location=config.vhost.mainsite.hostname,
+            identifier=self.identifier, key=self._root_secret)
+        macaroon.add_first_party_caveat(self.prefix + str(context))
+        return macaroon
+
+    def checkMacaroonIssuer(self, macaroon):
+        """See `IMacaroonIssuer`."""
+        if macaroon.location != config.vhost.mainsite.hostname:
+            return False
+        try:
+            verifier = Verifier()
+            verifier.satisfy_general(
+                lambda caveat: caveat.startswith(self.prefix))
+            return verifier.verify(macaroon, self._root_secret)
+        except Exception:
+            return False
+
+    def verifyCaveat(self, caveat_text, context):
+        """Verify the sole caveat on macaroons issued by this issuer.
+
+        :param caveat_text: The text of the caveat, with this issuer's
+            prefix removed.
+        :param context: The context to check.
+        :return: True if this caveat is allowed, otherwise False.
+        """
+        # We will need to change this interface if we ever support macaroons
+        # with more than one caveat or macaroons that are issued to users,
+        # but this is good enough for internal use.  Any unrecognised
+        # caveats will fail closed.
+        raise NotImplementedError
+
+    def verifyMacaroon(self, macaroon, context):
+        """See `IMacaroonIssuer`.
+
+        Concrete implementations should normally wrap this with some
+        additional checks of the context, and must implement `verifyCaveat`.
+        """
+        if not self.checkMacaroonIssuer(macaroon):
+            return False
+
+        def verify(caveat):
+            prefix = self.prefix
+            if not caveat.startswith(prefix):
+                return False
+            return self.verifyCaveat(caveat[len(prefix):], context)
+
+        try:
+            verifier = Verifier()
+            verifier.satisfy_general(verify)
+            return verifier.verify(macaroon, self._root_secret)
+        except Exception:
+            return False

=== modified file 'lib/lp/snappy/configure.zcml'
--- lib/lp/snappy/configure.zcml	2019-02-11 13:23:34 +0000
+++ lib/lp/snappy/configure.zcml	2019-03-12 17:19:58 +0000
@@ -83,6 +83,14 @@
         <allow interface="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource" />
     </securedutility>
 
+    <!-- SnapBuildMacaroonIssuer -->
+    <securedutility
+        class="lp.snappy.model.snapbuild.SnapBuildMacaroonIssuer"
+        provides="lp.services.macaroons.interfaces.IMacaroonIssuer"
+        name="snap-build">
+        <allow interface="lp.services.macaroons.interfaces.IMacaroonIssuerPublic"/>
+    </securedutility>
+
     <!-- SnapBuildBehaviour -->
     <adapter
         for="lp.snappy.interfaces.snapbuild.ISnapBuild"

=== modified file 'lib/lp/snappy/model/snapbuild.py'
--- lib/lp/snappy/model/snapbuild.py	2018-12-18 18:14:37 +0000
+++ lib/lp/snappy/model/snapbuild.py	2019-03-12 17:19:58 +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).
 
 __metaclass__ = type
@@ -35,6 +35,7 @@
 from zope.component.interfaces import ObjectEvent
 from zope.event import notify
 from zope.interface import implementer
+from zope.security.proxy import removeSecurityProxy
 
 from lp.app.errors import NotFoundError
 from lp.buildmaster.enums import (
@@ -45,6 +46,7 @@
 from lp.buildmaster.interfaces.buildfarmjob import IBuildFarmJobSource
 from lp.buildmaster.model.buildfarmjob import SpecificBuildFarmJobSourceMixin
 from lp.buildmaster.model.packagebuild import PackageBuildMixin
+from lp.code.interfaces.gitrepository import IGitRepository
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.registry.model.distribution import Distribution
 from lp.registry.model.distroseries import DistroSeries
@@ -65,6 +67,8 @@
     LibraryFileAlias,
     LibraryFileContent,
     )
+from lp.services.macaroons.interfaces import IMacaroonIssuer
+from lp.services.macaroons.model import MacaroonIssuerBase
 from lp.services.propertycache import (
     cachedproperty,
     get_property_cache,
@@ -584,3 +588,54 @@
             SnapBuild, SnapBuild.build_farm_job_id.is_in(
                 bfj.id for bfj in build_farm_jobs))
         return DecoratedResultSet(rows, pre_iter_hook=self.preloadBuildsData)
+
+
+@implementer(IMacaroonIssuer)
+class SnapBuildMacaroonIssuer(MacaroonIssuerBase):
+
+    identifier = "snap-build"
+
+    def issueMacaroon(self, context):
+        """See `IMacaroonIssuer`.
+
+        For issuing, the context is an `ISnapBuild` or its ID.
+        """
+        if ISnapBuild.providedBy(context):
+            pass
+        elif isinstance(context, int):
+            context = getUtility(ISnapBuildSet).getByID(context)
+        else:
+            raise ValueError("Cannot handle context %r." % context)
+        if not removeSecurityProxy(context).is_private:
+            raise ValueError("Refusing to issue macaroon for public build.")
+        return super(SnapBuildMacaroonIssuer, self).issueMacaroon(
+            removeSecurityProxy(context).id)
+
+    def verifyCaveat(self, caveat_text, context):
+        """See `MacaroonIssuerBase`.
+
+        For verification, the context is an `IGitRepository`.  We check that
+        the repository is needed to build the `ISnapBuild` that is the
+        context of the macaroon, and that the context build is currently
+        building.
+        """
+        # Circular import.
+        from lp.snappy.model.snap import Snap
+
+        try:
+            build_id = int(caveat_text)
+        except ValueError:
+            return False
+        return not IStore(SnapBuild).find(
+            SnapBuild,
+            SnapBuild.id == build_id,
+            SnapBuild.snap_id == Snap.id,
+            Snap.git_repository == context,
+            SnapBuild.status == BuildStatus.BUILDING).is_empty()
+
+    def verifyMacaroon(self, macaroon, context):
+        """See `IMacaroonIssuer`."""
+        if not IGitRepository.providedBy(context):
+            return False
+        return super(SnapBuildMacaroonIssuer, self).verifyMacaroon(
+            macaroon, context)

=== modified file 'lib/lp/snappy/tests/test_snapbuild.py'
--- lib/lp/snappy/tests/test_snapbuild.py	2018-12-18 18:23:52 +0000
+++ lib/lp/snappy/tests/test_snapbuild.py	2019-03-12 17:19:58 +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).
 
 """Test snap package build features."""
@@ -22,11 +22,13 @@
     Equals,
     Is,
     MatchesDict,
+    MatchesListwise,
     MatchesStructure,
     )
 from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
+from lp.app.enums import InformationType
 from lp.app.errors import NotFoundError
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.buildmaster.enums import BuildStatus
@@ -38,6 +40,7 @@
 from lp.services.features.testing import FeatureFixture
 from lp.services.job.interfaces.job import JobStatus
 from lp.services.librarian.browser import ProxiedLibraryFileAlias
+from lp.services.macaroons.interfaces import IMacaroonIssuer
 from lp.services.propertycache import clear_property_cache
 from lp.services.webapp.interfaces import OAuthPermission
 from lp.services.webapp.publisher import canonical_url
@@ -774,3 +777,124 @@
         browser = self.getNonRedirectingBrowser(user=self.person)
         for file_url in file_urls:
             self.assertCanOpenRedirectedUrl(browser, file_url)
+
+
+class TestSnapBuildMacaroonIssuer(TestCaseWithFactory):
+    """Test SnapBuild macaroon issuing and verification."""
+
+    layer = LaunchpadZopelessLayer
+
+    def setUp(self):
+        super(TestSnapBuildMacaroonIssuer, self).setUp()
+        self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
+        self.pushConfig(
+            "launchpad", internal_macaroon_secret_key="some-secret")
+
+    def test_issueMacaroon_refuses_public_snap(self):
+        build = self.factory.makeSnapBuild()
+        issuer = getUtility(IMacaroonIssuer, "snap-build")
+        self.assertRaises(
+            ValueError, removeSecurityProxy(issuer).issueMacaroon, build)
+
+    def test_issueMacaroon_good(self):
+        build = self.factory.makeSnapBuild(
+            snap=self.factory.makeSnap(private=True))
+        issuer = getUtility(IMacaroonIssuer, "snap-build")
+        macaroon = removeSecurityProxy(issuer).issueMacaroon(build)
+        self.assertEqual("launchpad.dev", macaroon.location)
+        self.assertEqual("snap-build", macaroon.identifier)
+        self.assertThat(macaroon.caveats, MatchesListwise([
+            MatchesStructure.byEquality(
+                caveat_id="lp.snap-build %s" % build.id),
+            ]))
+
+    def test_checkMacaroonIssuer_good(self):
+        build = self.factory.makeSnapBuild(
+            snap=self.factory.makeSnap(private=True))
+        issuer = getUtility(IMacaroonIssuer, "snap-build")
+        macaroon = removeSecurityProxy(issuer).issueMacaroon(build)
+        self.assertTrue(issuer.checkMacaroonIssuer(macaroon))
+
+    def test_checkMacaroonIssuer_wrong_location(self):
+        issuer = getUtility(IMacaroonIssuer, "snap-build")
+        macaroon = Macaroon(
+            location="another-location",
+            key=removeSecurityProxy(issuer)._root_secret)
+        self.assertFalse(issuer.checkMacaroonIssuer(macaroon))
+
+    def test_checkMacaroonIssuer_wrong_key(self):
+        issuer = getUtility(IMacaroonIssuer, "snap-build")
+        macaroon = Macaroon(
+            location=config.vhost.mainsite.hostname, key="another-secret")
+        self.assertFalse(issuer.checkMacaroonIssuer(macaroon))
+
+    def test_verifyMacaroon_good(self):
+        [ref] = self.factory.makeGitRefs(
+            information_type=InformationType.USERDATA)
+        build = self.factory.makeSnapBuild(
+            snap=self.factory.makeSnap(git_ref=ref, private=True))
+        build.updateStatus(BuildStatus.BUILDING)
+        issuer = removeSecurityProxy(
+            getUtility(IMacaroonIssuer, "snap-build"))
+        macaroon = issuer.issueMacaroon(build)
+        self.assertTrue(issuer.verifyMacaroon(macaroon, ref.repository))
+
+    def test_verifyMacaroon_wrong_location(self):
+        [ref] = self.factory.makeGitRefs(
+            information_type=InformationType.USERDATA)
+        build = self.factory.makeSnapBuild(
+            snap=self.factory.makeSnap(git_ref=ref, private=True))
+        build.updateStatus(BuildStatus.BUILDING)
+        issuer = removeSecurityProxy(
+            getUtility(IMacaroonIssuer, "snap-build"))
+        macaroon = Macaroon(
+            location="another-location", key=issuer._root_secret)
+        self.assertFalse(issuer.verifyMacaroon(macaroon, ref.repository))
+
+    def test_verifyMacaroon_wrong_key(self):
+        [ref] = self.factory.makeGitRefs(
+            information_type=InformationType.USERDATA)
+        build = self.factory.makeSnapBuild(
+            snap=self.factory.makeSnap(git_ref=ref, private=True))
+        build.updateStatus(BuildStatus.BUILDING)
+        issuer = removeSecurityProxy(
+            getUtility(IMacaroonIssuer, "snap-build"))
+        macaroon = Macaroon(
+            location=config.vhost.mainsite.hostname, key="another-secret")
+        self.assertFalse(issuer.verifyMacaroon(macaroon, ref.repository))
+
+    def test_verifyMacaroon_not_building(self):
+        [ref] = self.factory.makeGitRefs(
+            information_type=InformationType.USERDATA)
+        build = self.factory.makeSnapBuild(
+            snap=self.factory.makeSnap(git_ref=ref, private=True))
+        issuer = removeSecurityProxy(
+            getUtility(IMacaroonIssuer, "snap-build"))
+        macaroon = issuer.issueMacaroon(build)
+        self.assertFalse(issuer.verifyMacaroon(macaroon, ref.repository))
+
+    def test_verifyMacaroon_wrong_build(self):
+        [ref] = self.factory.makeGitRefs(
+            information_type=InformationType.USERDATA)
+        build = self.factory.makeSnapBuild(
+            snap=self.factory.makeSnap(git_ref=ref, private=True))
+        build.updateStatus(BuildStatus.BUILDING)
+        other_build = self.factory.makeSnapBuild(
+            snap=self.factory.makeSnap(private=True))
+        other_build.updateStatus(BuildStatus.BUILDING)
+        issuer = removeSecurityProxy(
+            getUtility(IMacaroonIssuer, "snap-build"))
+        macaroon = issuer.issueMacaroon(other_build)
+        self.assertFalse(issuer.verifyMacaroon(macaroon, ref.repository))
+
+    def test_verifyMacaroon_wrong_repository(self):
+        [ref] = self.factory.makeGitRefs(
+            information_type=InformationType.USERDATA)
+        build = self.factory.makeSnapBuild(
+            snap=self.factory.makeSnap(git_ref=ref, private=True))
+        other_repository = self.factory.makeGitRepository()
+        build.updateStatus(BuildStatus.BUILDING)
+        issuer = removeSecurityProxy(
+            getUtility(IMacaroonIssuer, "snap-build"))
+        macaroon = issuer.issueMacaroon(build)
+        self.assertFalse(issuer.verifyMacaroon(macaroon, other_repository))

=== modified file 'lib/lp/soyuz/model/binarypackagebuild.py'
--- lib/lp/soyuz/model/binarypackagebuild.py	2019-03-12 17:19:57 +0000
+++ lib/lp/soyuz/model/binarypackagebuild.py	2019-03-12 17:19:58 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -21,10 +21,6 @@
 
 import apt_pkg
 from debian.deb822 import PkgRelation
-from pymacaroons import (
-    Macaroon,
-    Verifier,
-    )
 import pytz
 from sqlobject import SQLObjectNotFound
 from storm.expr import (
@@ -86,6 +82,7 @@
     LibraryFileContent,
     )
 from lp.services.macaroons.interfaces import IMacaroonIssuer
+from lp.services.macaroons.model import MacaroonIssuerBase
 from lp.soyuz.adapters.buildarch import determine_architectures_to_build
 from lp.soyuz.enums import (
     ArchivePurpose,
@@ -1373,15 +1370,9 @@
 
 
 @implementer(IMacaroonIssuer)
-class BinaryPackageBuildMacaroonIssuer:
+class BinaryPackageBuildMacaroonIssuer(MacaroonIssuerBase):
 
-    @property
-    def _root_secret(self):
-        secret = config.launchpad.internal_macaroon_secret_key
-        if not secret:
-            raise RuntimeError(
-                "launchpad.internal_macaroon_secret_key not configured.")
-        return secret
+    identifier = "binary-package-build"
 
     def issueMacaroon(self, context):
         """See `IMacaroonIssuer`.
@@ -1390,27 +1381,11 @@
         """
         if not removeSecurityProxy(context).archive.private:
             raise ValueError("Refusing to issue macaroon for public build.")
-        macaroon = Macaroon(
-            location=config.vhost.mainsite.hostname,
-            identifier="binary-package-build", key=self._root_secret)
-        macaroon.add_first_party_caveat(
-            "lp.binary-package-build %s" % removeSecurityProxy(context).id)
-        return macaroon
-
-    def checkMacaroonIssuer(self, macaroon):
-        """See `IMacaroonIssuer`."""
-        if macaroon.location != config.vhost.mainsite.hostname:
-            return False
-        try:
-            verifier = Verifier()
-            verifier.satisfy_general(
-                lambda caveat: caveat.startswith("lp.binary-package-build "))
-            return verifier.verify(macaroon, self._root_secret)
-        except Exception:
-            return False
-
-    def verifyMacaroon(self, macaroon, context):
-        """See `IMacaroonIssuer`.
+        return super(BinaryPackageBuildMacaroonIssuer, self).issueMacaroon(
+            removeSecurityProxy(context).id)
+
+    def verifyCaveat(self, caveat_text, context):
+        """See `MacaroonIssuerBase`.
 
         For verification, the context is a `LibraryFileAlias` ID.  We check
         that the file is one of those required to build the
@@ -1420,32 +1395,23 @@
         # Circular import.
         from lp.soyuz.model.sourcepackagerelease import SourcePackageRelease
 
+        try:
+            build_id = int(caveat_text)
+        except ValueError:
+            return False
+        return not IStore(BinaryPackageBuild).find(
+            BinaryPackageBuild,
+            BinaryPackageBuild.id == build_id,
+            BinaryPackageBuild.source_package_release_id ==
+                SourcePackageRelease.id,
+            SourcePackageReleaseFile.sourcepackagereleaseID ==
+                SourcePackageRelease.id,
+            SourcePackageReleaseFile.libraryfileID == context,
+            BinaryPackageBuild.status == BuildStatus.BUILDING).is_empty()
+
+    def verifyMacaroon(self, macaroon, context):
+        """See `IMacaroonIssuer`."""
         if not isinstance(context, int):
             return False
-        if not self.checkMacaroonIssuer(macaroon):
-            return False
-
-        def verify_build(caveat):
-            prefix = "lp.binary-package-build "
-            if not caveat.startswith(prefix):
-                return False
-            try:
-                build_id = int(caveat[len(prefix):])
-            except ValueError:
-                return False
-            return not IStore(BinaryPackageBuild).find(
-                BinaryPackageBuild,
-                BinaryPackageBuild.id == build_id,
-                BinaryPackageBuild.source_package_release_id ==
-                    SourcePackageRelease.id,
-                SourcePackageReleaseFile.sourcepackagereleaseID ==
-                    SourcePackageRelease.id,
-                SourcePackageReleaseFile.libraryfileID == context,
-                BinaryPackageBuild.status == BuildStatus.BUILDING).is_empty()
-
-        try:
-            verifier = Verifier()
-            verifier.satisfy_general(verify_build)
-            return verifier.verify(macaroon, self._root_secret)
-        except Exception:
-            return False
+        return super(BinaryPackageBuildMacaroonIssuer, self).verifyMacaroon(
+            macaroon, context)


Follow ups