← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~pappacena/launchpad:ocirecipebuild-macaroon-issuer into launchpad:master

 

Thiago F. Pappacena has proposed merging ~pappacena/launchpad:ocirecipebuild-macaroon-issuer into launchpad:master.

Commit message:
Adding macaroon issuer for OCIRecipeBuild

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

This MP only adds the macaroon issuer itself, with some tests, based on the work done for Snaps. The usage of the macaroon will be done in a follow-up MP.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad:ocirecipebuild-macaroon-issuer into launchpad:master.
diff --git a/database/schema/security.cfg b/database/schema/security.cfg
index 11aba6f..29990e3 100644
--- a/database/schema/security.cfg
+++ b/database/schema/security.cfg
@@ -1426,6 +1426,7 @@ public.distroseries                     = SELECT, UPDATE
 public.distroseriesparent               = SELECT
 public.emailaddress                     = SELECT, INSERT, UPDATE
 public.flatpackagesetinclusion          = SELECT
+public.gitrepository                    = SELECT
 public.gpgkey                           = SELECT, INSERT
 public.job                              = SELECT, INSERT, UPDATE
 public.karma                            = SELECT, INSERT
diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml
index 1f7a6c7..c2ec9dd 100644
--- a/lib/lp/oci/configure.zcml
+++ b/lib/lp/oci/configure.zcml
@@ -1,4 +1,4 @@
-<!-- Copyright 2015-2020 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2015-2021 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 <configure
@@ -97,6 +97,14 @@
         factory="lp.oci.model.ocirecipebuildbehaviour.OCIRecipeBuildBehaviour"
         permission="zope.Public" />
 
+    <!-- OCIRecipeBuildMacaroonIssuer -->
+    <securedutility
+        class="lp.oci.model.ocirecipebuild.OCIRecipeBuildMacaroonIssuer"
+        provides="lp.services.macaroons.interfaces.IMacaroonIssuer"
+        name="ocirecipe-build">
+        <allow interface="lp.services.macaroons.interfaces.IMacaroonIssuerPublic"/>
+    </securedutility>
+
     <!-- OCIFile -->
     <class class="lp.oci.model.ocirecipebuild.OCIFile">
         <allow interface="lp.oci.interfaces.ocirecipebuild.IOCIFile" />
diff --git a/lib/lp/oci/interfaces/ocirecipe.py b/lib/lp/oci/interfaces/ocirecipe.py
index a27c5fd..633831f 100644
--- a/lib/lp/oci/interfaces/ocirecipe.py
+++ b/lib/lp/oci/interfaces/ocirecipe.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2020 Canonical Ltd.  This software is licensed under the
+# Copyright 2019-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Interfaces related to recipes for OCI Images."""
@@ -83,6 +83,7 @@ from lp.services.webhooks.interfaces import IWebhookTarget
 OCI_RECIPE_WEBHOOKS_FEATURE_FLAG = "oci.recipe.webhooks.enabled"
 OCI_RECIPE_ALLOW_CREATE = 'oci.recipe.create.enabled'
 OCI_RECIPE_BUILD_DISTRIBUTION = 'oci.default_build_distribution'
+OCI_RECIPE_PRIVATE_FEATURE_FLAG = "oci.recipe.allow_private"
 
 
 @error_status(http_client.UNAUTHORIZED)
diff --git a/lib/lp/oci/model/ocirecipebuild.py b/lib/lp/oci/model/ocirecipebuild.py
index c38bfa5..2e41955 100644
--- a/lib/lp/oci/model/ocirecipebuild.py
+++ b/lib/lp/oci/model/ocirecipebuild.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2020 Canonical Ltd.  This software is licensed under the
+# Copyright 2019-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """A build record for OCI Recipes."""
@@ -30,7 +30,10 @@ from storm.locals import (
 from storm.store import EmptyResultSet
 from zope.component import getUtility
 from zope.interface import implementer
-from zope.security.proxy import isinstance as zope_isinstance
+from zope.security.proxy import (
+    isinstance as zope_isinstance,
+    removeSecurityProxy,
+    )
 
 from lp.app.errors import NotFoundError
 from lp.buildmaster.enums import (
@@ -41,6 +44,7 @@ from lp.buildmaster.enums import (
 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.oci.interfaces.ocirecipe import IOCIRecipeSet
 from lp.oci.interfaces.ocirecipebuild import (
     CannotScheduleRegistryUpload,
@@ -73,6 +77,12 @@ 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.propertycache import (
     cachedproperty,
     get_property_cache,
@@ -253,6 +263,11 @@ class OCIRecipeBuild(PackageBuildMixin, Storm):
             ]
         return self.status in cancellable_statuses
 
+    @property
+    def is_private(self):
+        """See `IBuildFarmJob`."""
+        return self.recipe.git_ref.private
+
     def retry(self):
         """See `IOCIRecipeBuild`."""
         assert self.can_be_retried, "Build %s cannot be retried" % self.id
@@ -578,3 +593,62 @@ class OCIRecipeBuildSet(SpecificBuildFarmJobSourceMixin):
             OCIRecipeBuild, OCIRecipeBuild.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 OCIRecipeBuildMacaroonIssuer(MacaroonIssuerBase):
+
+    identifier = "ocirecipe-build"
+    issuable_via_authserver = True
+
+    def checkIssuingContext(self, context, **kwargs):
+        """See `MacaroonIssuerBase`.
+
+        For issuing, the context is an `IOCIRecipeBuild`.
+        """
+        if not IOCIRecipeBuild.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 IGitRepository.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 `IGitRepository`.  We check that
+        the repository is needed to build the `IOCIRecipeBuild` that is the
+        context of the macaroon, and that the context build is currently
+        building.
+        """
+        # Circular import.
+        from lp.oci.model.ocirecipe import OCIRecipe
+
+        # OCIRecipeBuild builds only support free-floating macaroons for Git
+        # authentication, not ones bound to a user.
+        if user:
+            return False
+        verified.user = NO_USER
+
+        if context is None:
+            # We're only verifying that the macaroon could be valid for some
+            # context.
+            return True
+
+        try:
+            build_id = int(caveat_value)
+        except ValueError:
+            return False
+        return not IStore(OCIRecipeBuild).find(
+            OCIRecipeBuild,
+            OCIRecipeBuild.id == build_id,
+            OCIRecipeBuild.recipe_id == OCIRecipe.id,
+            OCIRecipe.git_repository == context,
+            OCIRecipeBuild.status == BuildStatus.BUILDING).is_empty()
diff --git a/lib/lp/oci/tests/helpers.py b/lib/lp/oci/tests/helpers.py
index b0ee421..b656ca6 100644
--- a/lib/lp/oci/tests/helpers.py
+++ b/lib/lp/oci/tests/helpers.py
@@ -1,4 +1,4 @@
-# Copyright 2020 Canonical Ltd.  This software is licensed under the
+# Copyright 2020-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Helper methods and mixins for OCI tests."""
@@ -17,7 +17,10 @@ from testtools.matchers import (
     )
 from zope.security.proxy import removeSecurityProxy
 
-from lp.oci.interfaces.ocirecipe import OCI_RECIPE_ALLOW_CREATE
+from lp.oci.interfaces.ocirecipe import (
+    OCI_RECIPE_ALLOW_CREATE,
+    OCI_RECIPE_PRIVATE_FEATURE_FLAG,
+    )
 from lp.services.features.testing import FeatureFixture
 
 
@@ -35,7 +38,9 @@ class OCIConfigHelperMixin:
                 bytes(self.private_key)).decode("UTF-8"))
         # Default feature flags for our tests
         feature_flags = feature_flags or {}
-        feature_flags.update({OCI_RECIPE_ALLOW_CREATE: 'on'})
+        feature_flags.update({
+            OCI_RECIPE_ALLOW_CREATE: 'on',
+            OCI_RECIPE_PRIVATE_FEATURE_FLAG: 'on'})
         self.useFixture(FeatureFixture(feature_flags))
 
 
diff --git a/lib/lp/oci/tests/test_ocirecipebuild.py b/lib/lp/oci/tests/test_ocirecipebuild.py
index 53bb497..6cae750 100644
--- a/lib/lp/oci/tests/test_ocirecipebuild.py
+++ b/lib/lp/oci/tests/test_ocirecipebuild.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2020 Canonical Ltd.  This software is licensed under the
+# Copyright 2019-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for OCI image building recipe functionality."""
@@ -11,6 +11,7 @@ from datetime import (
     )
 
 from fixtures import FakeLogger
+from pymacaroons import Macaroon
 import pytz
 import six
 from testtools.matchers import (
@@ -18,11 +19,14 @@ from testtools.matchers import (
     Equals,
     Is,
     MatchesDict,
+    MatchesListwise,
     MatchesStructure,
     )
 from zope.component import getUtility
+from zope.publisher.xmlrpc import TestRequest
 from zope.security.proxy import removeSecurityProxy
 
+from lp.app.enums import InformationType
 from lp.app.errors import NotFoundError
 from lp.buildmaster.enums import BuildStatus
 from lp.buildmaster.interfaces.buildqueue import IBuildQueue
@@ -43,9 +47,15 @@ from lp.oci.interfaces.ocirecipebuildjob import IOCIRegistryUploadJobSource
 from lp.oci.model.ocirecipebuild import OCIRecipeBuildSet
 from lp.oci.tests.helpers import OCIConfigHelperMixin
 from lp.registry.interfaces.series import SeriesStatus
+from lp.services.authserver.xmlrpc import AuthServerAPIView
 from lp.services.config import config
 from lp.services.features.testing import FeatureFixture
 from lp.services.job.interfaces.job import JobStatus
+from lp.services.macaroons.interfaces import (
+    BadMacaroonContext,
+    IMacaroonIssuer,
+    )
+from lp.services.macaroons.testing import MacaroonTestMixin
 from lp.services.propertycache import clear_property_cache
 from lp.services.webapp.publisher import canonical_url
 from lp.services.webhooks.testing import LogsScheduledWebhooks
@@ -61,6 +71,7 @@ from lp.testing.layers import (
     LaunchpadZopelessLayer,
     )
 from lp.testing.matchers import HasQueryCount
+from lp.xmlrpc.interfaces import IPrivateApplication
 
 
 class TestOCIFileSet(TestCaseWithFactory):
@@ -641,3 +652,150 @@ class TestOCIRecipeBuildSet(TestCaseWithFactory):
         target = self.factory.makeOCIRecipeBuild(
             recipe=recipe, distro_arch_series=distro_arch_series)
         self.assertFalse(target.virtualized)
+
+
+class TestOCIRecipeBuildMacaroonIssuer(
+    MacaroonTestMixin, OCIConfigHelperMixin, TestCaseWithFactory):
+    """Test OCIRecipeBuild macaroon issuing and verification."""
+
+    layer = LaunchpadZopelessLayer
+
+    def setUp(self):
+        super(TestOCIRecipeBuildMacaroonIssuer, self).setUp()
+        self.setConfig()
+        self.pushConfig(
+            "launchpad", internal_macaroon_secret_key="some-secret")
+
+    def getPrivateBuild(self):
+        build = self.factory.makeOCIRecipeBuild()
+        build.recipe.git_ref.repository.transitionToInformationType(
+            InformationType.PRIVATESECURITY, build.recipe.registrant)
+        return build
+
+    def test_issueMacaroon_refuses_public_ocirecipebuild(self):
+        build = self.factory.makeOCIRecipeBuild()
+        issuer = getUtility(IMacaroonIssuer, "ocirecipe-build")
+        self.assertRaises(
+            BadMacaroonContext, removeSecurityProxy(issuer).issueMacaroon,
+            build)
+
+    def test_issueMacaroon_good(self):
+        build = self.getPrivateBuild()
+        issuer = getUtility(IMacaroonIssuer, "ocirecipe-build")
+        macaroon = removeSecurityProxy(issuer).issueMacaroon(build)
+        self.assertThat(macaroon, MatchesStructure(
+            location=Equals("launchpad.test"),
+            identifier=Equals("ocirecipe-build"),
+            caveats=MatchesListwise([
+                MatchesStructure.byEquality(
+                    caveat_id="lp.ocirecipe-build %s" % build.id),
+                ])))
+
+    def test_issueMacaroon_via_authserver(self):
+        build = self.getPrivateBuild()
+        private_root = getUtility(IPrivateApplication)
+        authserver = AuthServerAPIView(private_root.authserver, TestRequest())
+        macaroon = Macaroon.deserialize(
+            authserver.issueMacaroon(
+                "ocirecipe-build", "OCIRecipeBuild", build.id))
+        self.assertThat(macaroon, MatchesStructure(
+            location=Equals("launchpad.test"),
+            identifier=Equals("ocirecipe-build"),
+            caveats=MatchesListwise([
+                MatchesStructure.byEquality(
+                    caveat_id="lp.ocirecipe-build %s" % build.id),
+                ])))
+
+    def test_verifyMacaroon_good(self):
+        build = self.getPrivateBuild()
+        build.updateStatus(BuildStatus.BUILDING)
+        issuer = removeSecurityProxy(
+            getUtility(IMacaroonIssuer, "ocirecipe-build"))
+        macaroon = issuer.issueMacaroon(build)
+        self.assertMacaroonVerifies(
+            issuer, macaroon, build.recipe.git_ref.repository)
+
+    def test_verifyMacaroon_good_no_context(self):
+        build = self.getPrivateBuild()
+        build.updateStatus(BuildStatus.BUILDING)
+        issuer = removeSecurityProxy(
+            getUtility(IMacaroonIssuer, "ocirecipe-build"))
+        macaroon = issuer.issueMacaroon(build)
+        self.assertMacaroonVerifies(
+            issuer, macaroon, None, require_context=False)
+        self.assertMacaroonVerifies(
+            issuer, macaroon, build.recipe.git_ref.repository,
+            require_context=False)
+
+    def test_verifyMacaroon_no_context_but_require_context(self):
+        build = self.getPrivateBuild()
+        build.updateStatus(BuildStatus.BUILDING)
+        issuer = removeSecurityProxy(
+            getUtility(IMacaroonIssuer, "ocirecipe-build"))
+        macaroon = issuer.issueMacaroon(build)
+        self.assertMacaroonDoesNotVerify(
+            ["Expected macaroon verification context but got None."],
+            issuer, macaroon, None)
+
+    def test_verifyMacaroon_wrong_location(self):
+        build = self.getPrivateBuild()
+        build.updateStatus(BuildStatus.BUILDING)
+        issuer = removeSecurityProxy(
+            getUtility(IMacaroonIssuer, "ocirecipe-build"))
+        macaroon = Macaroon(
+            location="another-location", key=issuer._root_secret)
+        self.assertMacaroonDoesNotVerify(
+            ["Macaroon has unknown location 'another-location'."],
+            issuer, macaroon, build.recipe.git_ref.repository)
+        self.assertMacaroonDoesNotVerify(
+            ["Macaroon has unknown location 'another-location'."],
+            issuer, macaroon, build.recipe.git_ref.repository,
+            require_context=False)
+
+    def test_verifyMacaroon_wrong_key(self):
+        build = self.getPrivateBuild()
+        build.updateStatus(BuildStatus.BUILDING)
+        issuer = removeSecurityProxy(
+            getUtility(IMacaroonIssuer, "ocirecipe-build"))
+        macaroon = Macaroon(
+            location=config.vhost.mainsite.hostname, key="another-secret")
+        self.assertMacaroonDoesNotVerify(
+            ["Signatures do not match"],
+            issuer, macaroon, build.recipe.git_ref.repository)
+        self.assertMacaroonDoesNotVerify(
+            ["Signatures do not match"],
+            issuer, macaroon, build.recipe.git_ref.repository,
+            require_context=False)
+
+    def test_verifyMacaroon_not_building(self):
+        build = self.getPrivateBuild()
+        issuer = removeSecurityProxy(
+            getUtility(IMacaroonIssuer, "ocirecipe-build"))
+        macaroon = issuer.issueMacaroon(build)
+        self.assertMacaroonDoesNotVerify(
+            ["Caveat check for 'lp.ocirecipe-build %s' failed." % build.id],
+            issuer, macaroon, build.recipe.git_ref.repository)
+
+    def test_verifyMacaroon_wrong_build(self):
+        build = self.getPrivateBuild()
+        build.updateStatus(BuildStatus.BUILDING)
+        other_build = self.getPrivateBuild()
+        other_build.updateStatus(BuildStatus.BUILDING)
+        issuer = removeSecurityProxy(
+            getUtility(IMacaroonIssuer, "ocirecipe-build"))
+        macaroon = issuer.issueMacaroon(other_build)
+        self.assertMacaroonDoesNotVerify(
+            ["Caveat check for 'lp.ocirecipe-build %s' failed." %
+             other_build.id],
+            issuer, macaroon, build.recipe.git_ref.repository)
+
+    def test_verifyMacaroon_wrong_repository(self):
+        build = self.getPrivateBuild()
+        other_repository = self.factory.makeGitRepository()
+        build.updateStatus(BuildStatus.BUILDING)
+        issuer = removeSecurityProxy(
+            getUtility(IMacaroonIssuer, "ocirecipe-build"))
+        macaroon = issuer.issueMacaroon(build)
+        self.assertMacaroonDoesNotVerify(
+            ["Caveat check for 'lp.ocirecipe-build %s' failed." % build.id],
+            issuer, macaroon, other_repository)
diff --git a/lib/lp/services/authserver/xmlrpc.py b/lib/lp/services/authserver/xmlrpc.py
index 7e0f0ac..98ebb0c 100644
--- a/lib/lp/services/authserver/xmlrpc.py
+++ b/lib/lp/services/authserver/xmlrpc.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Auth-Server XML-RPC API ."""
@@ -19,6 +19,7 @@ from zope.component import (
 from zope.interface import implementer
 from zope.security.proxy import removeSecurityProxy
 
+from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
 from lp.registry.interfaces.person import IPersonSet
 from lp.services.authserver.interfaces import (
     IAuthServer,
@@ -68,6 +69,9 @@ class AuthServerAPIView(LaunchpadXMLRPCView):
         elif context_type == 'SnapBuild':
             # The context is a `SnapBuild` ID.
             return getUtility(ISnapBuildSet).getByID(context)
+        elif context_type == 'OCIRecipeBuild':
+            # The context is an OCIRecipe ID.
+            return getUtility(IOCIRecipeBuildSet).getByID(context)
         else:
             return None
 

Follow ups