launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #26116
[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