← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:livefsbuild-macaroons into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:livefsbuild-macaroons into launchpad:master with ~cjwatson/launchpad:bpb-macaroons-via-authserver as a prerequisite.

Commit message:
Add LiveFSBuild macaroons, allowing access to private archives

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/400787

Private live filesystem builds can use private archives, so in order to replace the buildd secret we need to be able to issue LiveFSBuild-scoped tokens that allow access to the appropriate archives.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:livefsbuild-macaroons into launchpad:master.
diff --git a/lib/lp/services/authserver/interfaces.py b/lib/lp/services/authserver/interfaces.py
index 69c8d58..4fbcd4a 100644
--- a/lib/lp/services/authserver/interfaces.py
+++ b/lib/lp/services/authserver/interfaces.py
@@ -34,8 +34,8 @@ class IAuthServer(Interface):
             `issuable_via_authserver` is True are permitted.
         :param context_type: A string identifying the type of context for
             which to issue the macaroon.  Currently only 'LibraryFileAlias',
-            'BinaryPackageBuild', 'SnapBuild', and 'OCIRecipeBuild' are
-            supported.
+            'BinaryPackageBuild', 'LiveFSBuild', 'SnapBuild', and
+            'OCIRecipeBuild' are supported.
         :param context: The context for which to issue the macaroon.  Note
             that this is passed over XML-RPC, so it should be plain data
             (e.g. an ID) rather than a database object.
@@ -48,7 +48,7 @@ class IAuthServer(Interface):
         :param macaroon_raw: A serialised macaroon.
         :param context_type: A string identifying the type of context to
             check.  Currently only 'LibraryFileAlias', 'BinaryPackageBuild',
-            'SnapBuild', and 'OCIRecipeBuild' are supported.
+            'LiveFSBuild', 'SnapBuild', and 'OCIRecipeBuild' are supported.
         :param context: The context to check.  Note that this is passed over
             XML-RPC, so it should be plain data (e.g. an ID) rather than a
             database object.
diff --git a/lib/lp/services/authserver/xmlrpc.py b/lib/lp/services/authserver/xmlrpc.py
index 2f8594d..06629ea 100644
--- a/lib/lp/services/authserver/xmlrpc.py
+++ b/lib/lp/services/authserver/xmlrpc.py
@@ -35,6 +35,7 @@ from lp.services.macaroons.interfaces import (
 from lp.services.webapp import LaunchpadXMLRPCView
 from lp.snappy.interfaces.snapbuild import ISnapBuildSet
 from lp.soyuz.interfaces.binarypackagebuild import IBinaryPackageBuildSet
+from lp.soyuz.interfaces.livefsbuild import ILiveFSBuildSet
 from lp.xmlrpc import faults
 
 
@@ -61,7 +62,7 @@ class AuthServerAPIView(LaunchpadXMLRPCView):
 
         :param context_type: A string identifying the type of context.
             Currently only 'LibraryFileAlias', 'BinaryPackageBuild',
-            'SnapBuild', and 'OCIRecipeBuild' are supported.
+            'LiveFSBuild', 'SnapBuild', and 'OCIRecipeBuild' are supported.
         :param context: The context as plain data (e.g. an ID).
         :return: The resolved context, or None.
         """
@@ -74,6 +75,9 @@ class AuthServerAPIView(LaunchpadXMLRPCView):
         elif context_type == 'BinaryPackageBuild':
             # The context is a `BinaryPackageBuild` ID.
             return getUtility(IBinaryPackageBuildSet).getByID(context)
+        elif context_type == 'LiveFSBuild':
+            # The context is a `LiveFSBuild` ID.
+            return getUtility(ILiveFSBuildSet).getByID(context)
         elif context_type == 'SnapBuild':
             # The context is a `SnapBuild` ID.
             return getUtility(ISnapBuildSet).getByID(context)
diff --git a/lib/lp/soyuz/configure.zcml b/lib/lp/soyuz/configure.zcml
index 9de2236..9c05d32 100644
--- a/lib/lp/soyuz/configure.zcml
+++ b/lib/lp/soyuz/configure.zcml
@@ -1067,6 +1067,14 @@
         <allow interface="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource"/>
     </securedutility>
 
+    <!-- LiveFSBuildMacaroonIssuer -->
+    <securedutility
+        class="lp.soyuz.model.livefsbuild.LiveFSBuildMacaroonIssuer"
+        provides="lp.services.macaroons.interfaces.IMacaroonIssuer"
+        name="livefs-build">
+        <allow interface="lp.services.macaroons.interfaces.IMacaroonIssuerPublic"/>
+    </securedutility>
+
     <!-- LiveFSBuildBehaviour -->
     <adapter
         for=".interfaces.livefsbuild.ILiveFSBuild"
diff --git a/lib/lp/soyuz/model/livefsbuild.py b/lib/lp/soyuz/model/livefsbuild.py
index 2bf500e..4c5e33c 100644
--- a/lib/lp/soyuz/model/livefsbuild.py
+++ b/lib/lp/soyuz/model/livefsbuild.py
@@ -11,12 +11,15 @@ from datetime import timedelta
 
 import pytz
 from storm.locals import (
+    And,
     Bool,
     DateTime,
     Desc,
     Int,
     JSON,
+    Or,
     Reference,
+    Select,
     Store,
     Storm,
     Unicode,
@@ -24,6 +27,7 @@ from storm.locals import (
 from storm.store import EmptyResultSet
 from zope.component import getUtility
 from zope.interface import implementer
+from zope.security.proxy import removeSecurityProxy
 
 from lp.app.errors import NotFoundError
 from lp.buildmaster.enums import (
@@ -50,7 +54,14 @@ from lp.services.librarian.model import (
     LibraryFileAlias,
     LibraryFileContent,
     )
+from lp.services.macaroons.interfaces import (
+    BadMacaroonContext,
+    IMacaroonIssuer,
+    NO_USER,
+    )
+from lp.services.macaroons.model import MacaroonIssuerBase
 from lp.services.webapp.snapshot import notify_modified
+from lp.soyuz.interfaces.archive import IArchive
 from lp.soyuz.interfaces.component import IComponentSet
 from lp.soyuz.interfaces.livefs import (
     LIVEFS_FEATURE_FLAG,
@@ -63,6 +74,7 @@ from lp.soyuz.interfaces.livefsbuild import (
     )
 from lp.soyuz.mail.livefsbuild import LiveFSBuildMailer
 from lp.soyuz.model.archive import Archive
+from lp.soyuz.model.archivedependency import ArchiveDependency
 
 
 @implementer(ILiveFSFile)
@@ -406,3 +418,72 @@ class LiveFSBuildSet(SpecificBuildFarmJobSourceMixin):
             LiveFSBuild, LiveFSBuild.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 LiveFSBuildMacaroonIssuer(MacaroonIssuerBase):
+
+    identifier = "livefs-build"
+    issuable_via_authserver = True
+
+    @property
+    def _primary_caveat_name(self):
+        """See `MacaroonIssuerBase`."""
+        # The "lp.principal" prefix indicates that this caveat constrains
+        # the macaroon to access only resources that should be accessible
+        # when acting on behalf of the named build, rather than to access
+        # the named build directly.
+        return "lp.principal.livefs-build"
+
+    def checkIssuingContext(self, context, **kwargs):
+        """See `MacaroonIssuerBase`.
+
+        For issuing, the context is an `ILiveFSBuild`.
+        """
+        if not ILiveFSBuild.providedBy(context):
+            raise BadMacaroonContext(context)
+        if not removeSecurityProxy(context).is_private:
+            raise BadMacaroonContext(
+                context, "Refusing to issue macaroon for public build.")
+        return removeSecurityProxy(context).id
+
+    def checkVerificationContext(self, context, **kwargs):
+        """See `MacaroonIssuerBase`."""
+        if not IArchive.providedBy(context):
+            raise BadMacaroonContext(context)
+        return context
+
+    def verifyPrimaryCaveat(self, verified, caveat_value, context, user=None,
+                            **kwargs):
+        """See `MacaroonIssuerBase`.
+
+        For verification, the context is an `IArchive`.  We check that the
+        archive is needed to build the `ILiveFSBuild` that is the context of
+        the macaroon, and that the context build is currently building.
+        """
+        # Live filesystem builds only support free-floating macaroons for
+        # Git authentication, not ones bound to a user.
+        if user:
+            return False
+        verified.user = NO_USER
+
+        try:
+            build_id = int(caveat_value)
+        except ValueError:
+            return False
+        clauses = [
+            LiveFSBuild.id == build_id,
+            LiveFSBuild.status == BuildStatus.BUILDING,
+            ]
+        if IArchive.providedBy(context):
+            clauses.append(
+                Or(
+                    LiveFSBuild.archive == context,
+                    LiveFSBuild.archive_id.is_in(Select(
+                        Archive.id,
+                        where=And(
+                            ArchiveDependency.archive == Archive.id,
+                            ArchiveDependency.dependency == context)))))
+        else:
+            return False
+        return not IStore(LiveFSBuild).find(LiveFSBuild, *clauses).is_empty()
diff --git a/lib/lp/soyuz/tests/test_livefsbuild.py b/lib/lp/soyuz/tests/test_livefsbuild.py
index 8d71dd7..35f084f 100644
--- a/lib/lp/soyuz/tests/test_livefsbuild.py
+++ b/lib/lp/soyuz/tests/test_livefsbuild.py
@@ -13,6 +13,7 @@ from datetime import (
     )
 
 from fixtures import FakeLogger
+from pymacaroons import Macaroon
 import pytz
 from six.moves.urllib.parse import urlsplit
 from six.moves.urllib.request import urlopen
@@ -20,9 +21,11 @@ from testtools.matchers import (
     ContainsDict,
     Equals,
     MatchesDict,
+    MatchesListwise,
     MatchesStructure,
     )
 from zope.component import getUtility
+from zope.publisher.xmlrpc import TestRequest
 from zope.security.proxy import removeSecurityProxy
 
 from lp.app.errors import NotFoundError
@@ -35,9 +38,16 @@ from lp.buildmaster.interfaces.buildqueue import IBuildQueue
 from lp.buildmaster.interfaces.packagebuild import IPackageBuild
 from lp.buildmaster.interfaces.processor import IProcessorSet
 from lp.registry.enums import PersonVisibility
+from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.services.authserver.xmlrpc import AuthServerAPIView
 from lp.services.config import config
 from lp.services.features.testing import FeatureFixture
 from lp.services.librarian.browser import ProxiedLibraryFileAlias
+from lp.services.macaroons.interfaces import (
+    BadMacaroonContext,
+    IMacaroonIssuer,
+    )
+from lp.services.macaroons.testing import MacaroonTestMixin
 from lp.services.webapp.interfaces import OAuthPermission
 from lp.services.webapp.publisher import canonical_url
 from lp.services.webhooks.testing import LogsScheduledWebhooks
@@ -66,6 +76,7 @@ from lp.testing.layers import (
     )
 from lp.testing.mail_helpers import pop_notifications
 from lp.testing.pages import webservice_for_person
+from lp.xmlrpc.interfaces import IPrivateApplication
 
 
 class TestLiveFSBuildFeatureFlag(TestCaseWithFactory):
@@ -584,3 +595,146 @@ class TestLiveFSBuildWebservice(TestCaseWithFactory):
             resp = self.webservice.get(path)
             self.assertEqual(303, resp.status)
             urlopen(resp.getheader('Location')).close()
+
+
+class TestLiveFSBuildMacaroonIssuer(MacaroonTestMixin, TestCaseWithFactory):
+    """Test LiveFSBuild macaroon issuing and verification."""
+
+    layer = LaunchpadZopelessLayer
+
+    def setUp(self):
+        super(TestLiveFSBuildMacaroonIssuer, self).setUp()
+        self.useFixture(FeatureFixture({LIVEFS_FEATURE_FLAG: "on"}))
+        self.pushConfig(
+            "launchpad", internal_macaroon_secret_key="some-secret")
+
+    def test_issueMacaroon_refuses_public_snap(self):
+        build = self.factory.makeLiveFSBuild()
+        issuer = getUtility(IMacaroonIssuer, "livefs-build")
+        self.assertRaises(
+            BadMacaroonContext, removeSecurityProxy(issuer).issueMacaroon,
+            build)
+
+    def test_issueMacaroon_good(self):
+        livefs = self.factory.makeLiveFS()
+        archive = self.factory.makeArchive(owner=livefs.owner, private=True)
+        build = self.factory.makeLiveFSBuild(livefs=livefs, archive=archive)
+        issuer = getUtility(IMacaroonIssuer, "livefs-build")
+        macaroon = removeSecurityProxy(issuer).issueMacaroon(build)
+        self.assertThat(macaroon, MatchesStructure(
+            location=Equals("launchpad.test"),
+            identifier=Equals("livefs-build"),
+            caveats=MatchesListwise([
+                MatchesStructure.byEquality(
+                    caveat_id="lp.principal.livefs-build %s" % build.id),
+                ])))
+
+    def test_issueMacaroon_via_authserver(self):
+        livefs = self.factory.makeLiveFS()
+        archive = self.factory.makeArchive(owner=livefs.owner, private=True)
+        build = self.factory.makeLiveFSBuild(livefs=livefs, archive=archive)
+        private_root = getUtility(IPrivateApplication)
+        authserver = AuthServerAPIView(private_root.authserver, TestRequest())
+        macaroon = Macaroon.deserialize(
+            authserver.issueMacaroon("livefs-build", "LiveFSBuild", build.id))
+        self.assertThat(macaroon, MatchesStructure(
+            location=Equals("launchpad.test"),
+            identifier=Equals("livefs-build"),
+            caveats=MatchesListwise([
+                MatchesStructure.byEquality(
+                    caveat_id="lp.principal.livefs-build %s" % build.id),
+                ])))
+
+    def test_verifyMacaroon_good_direct_archive(self):
+        livefs = self.factory.makeLiveFS()
+        archive = self.factory.makeArchive(owner=livefs.owner, private=True)
+        build = self.factory.makeLiveFSBuild(livefs=livefs, archive=archive)
+        build.updateStatus(BuildStatus.BUILDING)
+        issuer = removeSecurityProxy(
+            getUtility(IMacaroonIssuer, "livefs-build"))
+        macaroon = issuer.issueMacaroon(build)
+        self.assertMacaroonVerifies(issuer, macaroon, archive)
+
+    def test_verifyMacaroon_good_indirect_archive(self):
+        livefs = self.factory.makeLiveFS()
+        archive = self.factory.makeArchive(owner=livefs.owner, private=True)
+        build = self.factory.makeLiveFSBuild(livefs=livefs, archive=archive)
+        dependency = self.factory.makeArchive(
+            distribution=build.archive.distribution, private=True)
+        archive.addArchiveDependency(
+            dependency, PackagePublishingPocket.RELEASE)
+        build.updateStatus(BuildStatus.BUILDING)
+        issuer = removeSecurityProxy(
+            getUtility(IMacaroonIssuer, "livefs-build"))
+        macaroon = issuer.issueMacaroon(build)
+        self.assertMacaroonVerifies(issuer, macaroon, dependency)
+
+    def test_verifyMacaroon_wrong_location(self):
+        livefs = self.factory.makeLiveFS()
+        archive = self.factory.makeArchive(owner=livefs.owner, private=True)
+        build = self.factory.makeLiveFSBuild(livefs=livefs, archive=archive)
+        build.updateStatus(BuildStatus.BUILDING)
+        issuer = removeSecurityProxy(
+            getUtility(IMacaroonIssuer, "livefs-build"))
+        macaroon = Macaroon(
+            location="another-location", key=issuer._root_secret)
+        self.assertMacaroonDoesNotVerify(
+            ["Macaroon has unknown location 'another-location'."],
+            issuer, macaroon, archive)
+
+    def test_verifyMacaroon_wrong_key(self):
+        livefs = self.factory.makeLiveFS()
+        archive = self.factory.makeArchive(owner=livefs.owner, private=True)
+        build = self.factory.makeLiveFSBuild(livefs=livefs, archive=archive)
+        build.updateStatus(BuildStatus.BUILDING)
+        issuer = removeSecurityProxy(
+            getUtility(IMacaroonIssuer, "livefs-build"))
+        macaroon = Macaroon(
+            location=config.vhost.mainsite.hostname, key="another-secret")
+        self.assertMacaroonDoesNotVerify(
+            ["Signatures do not match"], issuer, macaroon, archive)
+
+    def test_verifyMacaroon_not_building(self):
+        livefs = self.factory.makeLiveFS()
+        archive = self.factory.makeArchive(owner=livefs.owner, private=True)
+        build = self.factory.makeLiveFSBuild(livefs=livefs, archive=archive)
+        issuer = removeSecurityProxy(
+            getUtility(IMacaroonIssuer, "livefs-build"))
+        macaroon = issuer.issueMacaroon(build)
+        self.assertMacaroonDoesNotVerify(
+            ["Caveat check for 'lp.principal.livefs-build %s' failed." %
+                build.id],
+            issuer, macaroon, archive)
+
+    def test_verifyMacaroon_wrong_build(self):
+        livefs = self.factory.makeLiveFS()
+        archive = self.factory.makeArchive(owner=livefs.owner, private=True)
+        build = self.factory.makeLiveFSBuild(livefs=livefs, archive=archive)
+        build.updateStatus(BuildStatus.BUILDING)
+        other_build = self.factory.makeLiveFSBuild(
+            livefs=livefs,
+            archive=self.factory.makeArchive(
+                owner=livefs.owner, private=True))
+        other_build.updateStatus(BuildStatus.BUILDING)
+        issuer = removeSecurityProxy(
+            getUtility(IMacaroonIssuer, "livefs-build"))
+        macaroon = issuer.issueMacaroon(other_build)
+        self.assertMacaroonDoesNotVerify(
+            ["Caveat check for 'lp.principal.livefs-build %s' failed." %
+                other_build.id],
+            issuer, macaroon, archive)
+
+    def test_verifyMacaroon_wrong_archive(self):
+        livefs = self.factory.makeLiveFS()
+        archive = self.factory.makeArchive(owner=livefs.owner, private=True)
+        build = self.factory.makeLiveFSBuild(livefs=livefs, archive=archive)
+        other_archive = self.factory.makeArchive(
+            distribution=archive.distribution, private=True)
+        build.updateStatus(BuildStatus.BUILDING)
+        issuer = removeSecurityProxy(
+            getUtility(IMacaroonIssuer, "livefs-build"))
+        macaroon = issuer.issueMacaroon(build)
+        self.assertMacaroonDoesNotVerify(
+            ["Caveat check for 'lp.principal.livefs-build %s' failed." %
+                build.id],
+            issuer, macaroon, other_archive)