launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #32265
[Merge] ~ruinedyourlife/launchpad:allow-private-resources-sourcecraft-builds into launchpad:master
Quentin Debhi has proposed merging ~ruinedyourlife/launchpad:allow-private-resources-sourcecraft-builds into launchpad:master.
Commit message:
Allow private resources for sourcecraft builds
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~ruinedyourlife/launchpad/+git/launchpad/+merge/481888
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~ruinedyourlife/launchpad:allow-private-resources-sourcecraft-builds into launchpad:master.
diff --git a/lib/lp/code/xmlrpc/tests/test_git.py b/lib/lp/code/xmlrpc/tests/test_git.py
index 05155f4..9046f3e 100644
--- a/lib/lp/code/xmlrpc/tests/test_git.py
+++ b/lib/lp/code/xmlrpc/tests/test_git.py
@@ -48,6 +48,10 @@ from lp.code.interfaces.gitrepository import (
from lp.code.model.gitjob import GitRefScanJob
from lp.code.tests.helpers import GitHostingFixture
from lp.code.xmlrpc.git import GIT_ASYNC_CREATE_REPO
+from lp.crafts.interfaces.craftrecipe import (
+ CRAFT_RECIPE_ALLOW_CREATE,
+ CRAFT_RECIPE_PRIVATE_FEATURE_FLAG,
+)
from lp.registry.enums import TeamMembershipPolicy
from lp.services.auth.enums import AccessTokenScope
from lp.services.config import config
@@ -2569,6 +2573,96 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
macaroon_raw=macaroons[0].serialize(),
)
+ def test_translatePath_private_craft_recipe_build(self):
+ # A builder with a suitable macaroon can read from a repository
+ # associated with a running private craft recipe build.
+ self.useFixture(
+ FeatureFixture(
+ {
+ CRAFT_RECIPE_ALLOW_CREATE: "on",
+ CRAFT_RECIPE_PRIVATE_FEATURE_FLAG: "on",
+ }
+ )
+ )
+ self.pushConfig(
+ "launchpad", internal_macaroon_secret_key="some-secret"
+ )
+ owner = self.factory.makePerson()
+ with person_logged_in(owner):
+ refs = [
+ self.factory.makeGitRefs(
+ owner=owner, information_type=InformationType.USERDATA
+ )[0]
+ for _ in range(2)
+ ]
+ builds = [
+ self.factory.makeCraftRecipeBuild(
+ requester=owner,
+ owner=owner,
+ git_ref=ref,
+ recipe=self.factory.makeCraftRecipe(
+ owner=owner,
+ registrant=owner,
+ git_ref=ref,
+ information_type=InformationType.PROPRIETARY,
+ ),
+ )
+ for ref in refs
+ ]
+ issuer = getUtility(IMacaroonIssuer, "craft-recipe-build")
+ macaroons = [
+ removeSecurityProxy(issuer).issueMacaroon(build)
+ for build in builds
+ ]
+ repository = refs[0].repository
+ registrant = repository.registrant
+ path = "/%s" % repository.unique_name
+ self.assertUnauthorized(
+ LAUNCHPAD_SERVICES,
+ path,
+ permission="write",
+ macaroon_raw=macaroons[0].serialize(),
+ )
+ with person_logged_in(owner):
+ builds[0].updateStatus(BuildStatus.BUILDING)
+ self.assertTranslates(
+ LAUNCHPAD_SERVICES,
+ path,
+ repository,
+ permission="read",
+ macaroon_raw=macaroons[0].serialize(),
+ private=True,
+ writable=False,
+ )
+ self.assertUnauthorized(
+ LAUNCHPAD_SERVICES,
+ path,
+ permission="read",
+ macaroon_raw=macaroons[1].serialize(),
+ )
+ self.assertUnauthorized(
+ LAUNCHPAD_SERVICES,
+ path,
+ permission="read",
+ macaroon_raw=Macaroon(
+ location=config.vhost.mainsite.hostname,
+ identifier="another",
+ key="another-secret",
+ ).serialize(),
+ )
+ self.assertUnauthorized(
+ LAUNCHPAD_SERVICES,
+ path,
+ permission="read",
+ macaroon_raw="nonsense",
+ )
+ self.assertUnauthorized(
+ registrant,
+ path,
+ permission="read",
+ macaroon_raw=macaroons[0].serialize(),
+ )
+
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.
@@ -3576,6 +3670,64 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
"nonsense",
)
+ def test_authenticateWithPassword_private_craft_recipe_build(self):
+ self.useFixture(
+ FeatureFixture(
+ {
+ CRAFT_RECIPE_ALLOW_CREATE: "on",
+ CRAFT_RECIPE_PRIVATE_FEATURE_FLAG: "on",
+ }
+ )
+ )
+ self.pushConfig(
+ "launchpad", internal_macaroon_secret_key="some-secret"
+ )
+ with person_logged_in(self.factory.makePerson()) as owner:
+ [ref] = self.factory.makeGitRefs(
+ owner=owner, information_type=InformationType.USERDATA
+ )
+ recipe = self.factory.makeCraftRecipe(
+ owner=owner,
+ registrant=owner,
+ git_ref=ref,
+ information_type=InformationType.PROPRIETARY,
+ )
+ build = self.factory.makeCraftRecipeBuild(
+ requester=owner, owner=owner, git_ref=ref, recipe=recipe
+ )
+ issuer = getUtility(IMacaroonIssuer, "craft-recipe-build")
+ macaroon = removeSecurityProxy(issuer).issueMacaroon(build)
+ for username in ("", "+launchpad-services"):
+ self.assertEqual(
+ {
+ "macaroon": macaroon.serialize(),
+ "user": "+launchpad-services",
+ },
+ self.assertDoesNotFault(
+ None,
+ "authenticateWithPassword",
+ username,
+ macaroon.serialize(),
+ ),
+ )
+ other_macaroon = Macaroon(
+ identifier="another", key="another-secret"
+ )
+ self.assertFault(
+ faults.Unauthorized,
+ None,
+ "authenticateWithPassword",
+ username,
+ other_macaroon.serialize(),
+ )
+ self.assertFault(
+ faults.Unauthorized,
+ None,
+ "authenticateWithPassword",
+ username,
+ "nonsense",
+ )
+
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
@@ -3971,6 +4123,46 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
macaroon_raw=macaroon.serialize(),
)
+ def test_checkRefPermissions_private_craft_recipe_build(self):
+ # A builder with a suitable macaroon cannot write to a repository,
+ # even if it is associated with a running private craft recipe build.
+ self.useFixture(
+ FeatureFixture(
+ {
+ CRAFT_RECIPE_ALLOW_CREATE: "on",
+ CRAFT_RECIPE_PRIVATE_FEATURE_FLAG: "on",
+ }
+ )
+ )
+ self.pushConfig(
+ "launchpad", internal_macaroon_secret_key="some-secret"
+ )
+ with person_logged_in(self.factory.makePerson()) as owner:
+ [ref] = self.factory.makeGitRefs(
+ owner=owner, information_type=InformationType.USERDATA
+ )
+ recipe = self.factory.makeCraftRecipe(
+ owner=owner,
+ registrant=owner,
+ git_ref=ref,
+ information_type=InformationType.PROPRIETARY,
+ )
+ build = self.factory.makeCraftRecipeBuild(
+ requester=owner, owner=owner, git_ref=ref, recipe=recipe
+ )
+ issuer = getUtility(IMacaroonIssuer, "craft-recipe-build")
+ macaroon = removeSecurityProxy(issuer).issueMacaroon(build)
+ build.updateStatus(BuildStatus.BUILDING)
+ repository = ref.repository
+ path = ref.path.encode("UTF-8")
+ self.assertHasRefPermissions(
+ LAUNCHPAD_SERVICES,
+ repository,
+ [path],
+ {path: []},
+ macaroon_raw=macaroon.serialize(),
+ )
+
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
diff --git a/lib/lp/crafts/configure.zcml b/lib/lp/crafts/configure.zcml
index 0ec2393..a13bd3f 100644
--- a/lib/lp/crafts/configure.zcml
+++ b/lib/lp/crafts/configure.zcml
@@ -102,4 +102,12 @@
<allow interface="lp.crafts.interfaces.craftrecipejob.ICraftRecipeRequestBuildsJob" />
</class>
<webservice:register module="lp.crafts.interfaces.webservice" />
+
+ <!-- CraftRecipeBuildMacaroonIssuer -->
+ <lp:securedutility
+ class="lp.crafts.model.craftrecipebuild.CraftRecipeBuildMacaroonIssuer"
+ provides="lp.services.macaroons.interfaces.IMacaroonIssuer"
+ name="craft-recipe-build">
+ <allow interface="lp.services.macaroons.interfaces.IMacaroonIssuerPublic"/>
+ </lp:securedutility>
</configure>
diff --git a/lib/lp/crafts/model/craftrecipebuild.py b/lib/lp/crafts/model/craftrecipebuild.py
index d84b466..8fa4baa 100644
--- a/lib/lp/crafts/model/craftrecipebuild.py
+++ b/lib/lp/crafts/model/craftrecipebuild.py
@@ -16,6 +16,7 @@ from storm.locals import Bool, DateTime, Desc, Int, Reference, Store, Unicode
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.builderproxy import BUILD_METADATA_FILENAME_FORMAT
@@ -27,6 +28,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.crafts.interfaces.craftrecipebuild import (
ICraftFile,
ICraftRecipeBuild,
@@ -47,6 +49,12 @@ from lp.services.database.interfaces import IPrimaryStore, IStore
from lp.services.database.stormbase import StormBase
from lp.services.librarian.browser import ProxiedLibraryFileAlias
from lp.services.librarian.model import LibraryFileAlias, LibraryFileContent
+from lp.services.macaroons.interfaces import (
+ NO_USER,
+ BadMacaroonContext,
+ IMacaroonIssuer,
+)
+from lp.services.macaroons.model import MacaroonIssuerBase
from lp.services.propertycache import cachedproperty, get_property_cache
from lp.services.webapp.snapshot import notify_modified
from lp.soyuz.model.distroarchseries import DistroArchSeries
@@ -460,6 +468,71 @@ class CraftRecipeBuildSet(SpecificBuildFarmJobSourceMixin):
return DecoratedResultSet(rows, pre_iter_hook=self.preloadBuildsData)
+@implementer(IMacaroonIssuer)
+class CraftRecipeBuildMacaroonIssuer(MacaroonIssuerBase):
+ identifier = "craft-recipe-build"
+ issuable_via_authserver = True
+
+ def checkIssuingContext(self, context, **kwargs):
+ """See `MacaroonIssuerBase`.
+
+ For issuing, the context is an `ICraftRecipeBuild`.
+ """
+ if not ICraftRecipeBuild.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 `ICraftRecipeBuild` that is the
+ context of the macaroon, and that the context build is currently
+ building.
+ """
+ # Circular import.
+ from lp.crafts.model.craftrecipe import CraftRecipe
+
+ # CraftRecipeBuild 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(CraftRecipeBuild)
+ .find(
+ CraftRecipeBuild,
+ CraftRecipeBuild.id == build_id,
+ CraftRecipeBuild.recipe_id == CraftRecipe.id,
+ CraftRecipe.git_repository == context,
+ CraftRecipeBuild.status == BuildStatus.BUILDING,
+ )
+ .is_empty()
+ )
+
+
@implementer(ICraftFile)
class CraftFile(StormBase):
"""See `ICraftFile`."""
diff --git a/lib/lp/crafts/model/craftrecipebuildbehaviour.py b/lib/lp/crafts/model/craftrecipebuildbehaviour.py
index 50cfe6d..839b4c2 100644
--- a/lib/lp/crafts/model/craftrecipebuildbehaviour.py
+++ b/lib/lp/crafts/model/craftrecipebuildbehaviour.py
@@ -27,8 +27,11 @@ from lp.buildmaster.interfaces.buildfarmjobbehaviour import (
from lp.buildmaster.model.buildfarmjobbehaviour import (
BuildFarmJobBehaviourBase,
)
+from lp.code.interfaces.codehosting import LAUNCHPAD_SERVICES
from lp.crafts.interfaces.craftrecipebuild import ICraftRecipeBuild
from lp.registry.interfaces.series import SeriesStatus
+from lp.services.config import config
+from lp.services.twistedsupport import cancel_on_timeout
from lp.soyuz.adapters.archivedependencies import get_sources_list_for_building
@@ -72,6 +75,18 @@ class CraftRecipeBuildBehaviour(BuilderProxyMixin, BuildFarmJobBehaviourBase):
"Missing chroot for %s" % build.distro_arch_series.displayname
)
+ def issueMacaroon(self):
+ """See `IBuildFarmJobBehaviour`."""
+ return cancel_on_timeout(
+ self._authserver.callRemote(
+ "issueMacaroon",
+ "craft-recipe-build",
+ "CraftRecipeBuild",
+ self.build.id,
+ ),
+ config.builddmaster.authentication_timeout,
+ )
+
@defer.inlineCallbacks
def extraBuildArgs(self, logger=None) -> Generator[Any, Any, BuildArgs]:
"""
@@ -79,6 +94,28 @@ class CraftRecipeBuildBehaviour(BuilderProxyMixin, BuildFarmJobBehaviourBase):
"""
build = self.build
args: BuildArgs = yield super().extraBuildArgs(logger=logger)
+
+ if logger is not None:
+ logger.debug("Build recipe: %r", build.recipe)
+ logger.debug("Git ref: %r", build.recipe.git_ref)
+ if build.recipe.git_ref is not None:
+ logger.debug(
+ "Git ref repository URL: %r",
+ build.recipe.git_ref.repository_url,
+ )
+ logger.debug("Git repository: %r", build.recipe.git_repository)
+ logger.debug(
+ "Git repository HTTPS URL: %r",
+ build.recipe.git_repository.git_https_url,
+ )
+ logger.debug("Git path: %r", build.recipe.git_path)
+ logger.debug("Git ref name: %r", build.recipe.git_ref.name)
+ logger.debug(
+ "Recipe information type: %r",
+ build.recipe.information_type,
+ )
+ logger.debug("Is private: %r", build.is_private)
+
yield self.startProxySession(
args,
use_fetch_service=build.recipe.use_fetch_service,
@@ -101,12 +138,16 @@ class CraftRecipeBuildBehaviour(BuilderProxyMixin, BuildFarmJobBehaviourBase):
if build.recipe.git_ref.repository_url is not None:
args["git_repository"] = build.recipe.git_ref.repository_url
else:
- args["git_repository"] = (
- build.recipe.git_repository.git_https_url
- )
- # "git clone -b" doesn't accept full ref names. If this becomes
- # a problem then we could change launchpad-buildd to do "git
- # clone" followed by "git checkout" instead.
+ repo = build.recipe.git_repository
+ if repo.private:
+ macaroon_raw = yield self.issueMacaroon()
+ url = repo.getCodebrowseUrl(
+ username=LAUNCHPAD_SERVICES, password=macaroon_raw
+ )
+ args["git_repository"] = url
+ else:
+ args["git_repository"] = repo.git_https_url
+
if build.recipe.git_path != "HEAD":
args["git_path"] = build.recipe.git_ref.name
else:
diff --git a/lib/lp/crafts/tests/test_craftrecipebuild.py b/lib/lp/crafts/tests/test_craftrecipebuild.py
index c583d9c..346efba 100644
--- a/lib/lp/crafts/tests/test_craftrecipebuild.py
+++ b/lib/lp/crafts/tests/test_craftrecipebuild.py
@@ -7,8 +7,16 @@ from datetime import datetime, timedelta, timezone
from urllib.request import urlopen
import six
-from testtools.matchers import ContainsDict, Equals, Is
+from pymacaroons import Macaroon
+from testtools.matchers import (
+ ContainsDict,
+ Equals,
+ Is,
+ 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
@@ -29,11 +37,17 @@ from lp.crafts.interfaces.craftrecipebuild import (
from lp.crafts.model.craftrecipebuild import CraftRecipeBuild
from lp.registry.enums import PersonVisibility, TeamMembershipPolicy
from lp.registry.interfaces.series import SeriesStatus
+from lp.services.authserver.xmlrpc import AuthServerAPIView
from lp.services.config import config
from lp.services.database.interfaces import IStore
from lp.services.database.sqlbase import flush_database_caches
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.propertycache import clear_property_cache
from lp.services.webapp.interfaces import OAuthPermission
from lp.testing import (
@@ -49,6 +63,7 @@ from lp.testing.layers import LaunchpadFunctionalLayer, LaunchpadZopelessLayer
from lp.testing.mail_helpers import pop_notifications
from lp.testing.matchers import HasQueryCount
from lp.testing.pages import webservice_for_person
+from lp.xmlrpc.interfaces import IPrivateApplication
expected_body = """\
* Craft Recipe: craft-1
@@ -715,3 +730,227 @@ class TestCraftRecipeBuildWebservice(TestCaseWithFactory):
logout()
build = self.webservice.get(build_url).jsonBody()
self.assertIsNone(build["build_metadata_url"])
+
+
+class TestCraftRecipeBuildMacaroonIssuer(
+ MacaroonTestMixin, TestCaseWithFactory
+):
+ """Test CraftRecipeBuild macaroon issuing and verification."""
+
+ layer = LaunchpadZopelessLayer
+
+ def setUp(self):
+ super().setUp()
+ self.useFixture(
+ FeatureFixture(
+ {
+ CRAFT_RECIPE_ALLOW_CREATE: "on",
+ CRAFT_RECIPE_PRIVATE_FEATURE_FLAG: "on",
+ }
+ )
+ )
+ self.pushConfig(
+ "launchpad", internal_macaroon_secret_key="some-secret"
+ )
+
+ def getPrivateBuild(self):
+ owner = self.factory.makePerson()
+ recipe = self.factory.makeCraftRecipe(
+ owner=owner,
+ registrant=owner,
+ information_type=InformationType.PROPRIETARY,
+ )
+ build = self.factory.makeCraftRecipeBuild(
+ recipe=recipe, requester=owner
+ )
+ build.recipe.git_ref.repository.transitionToInformationType(
+ InformationType.PRIVATESECURITY, build.recipe.registrant
+ )
+ return build
+
+ def test_issueMacaroon_refuses_public_craftrecipebuild(self):
+ build = self.factory.makeCraftRecipeBuild()
+ issuer = getUtility(IMacaroonIssuer, "craft-recipe-build")
+ self.assertRaises(
+ BadMacaroonContext,
+ removeSecurityProxy(issuer).issueMacaroon,
+ build,
+ )
+
+ def test_issueMacaroon_good(self):
+ build = self.getPrivateBuild()
+ issuer = getUtility(IMacaroonIssuer, "craft-recipe-build")
+ macaroon = removeSecurityProxy(issuer).issueMacaroon(build)
+ self.assertThat(
+ macaroon,
+ MatchesStructure(
+ location=Equals("launchpad.test"),
+ identifier=Equals("craft-recipe-build"),
+ caveats=MatchesListwise(
+ [
+ MatchesStructure.byEquality(
+ caveat_id="lp.craft-recipe-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(
+ "craft-recipe-build", "CraftRecipeBuild", build.id
+ )
+ )
+ self.assertThat(
+ macaroon,
+ MatchesStructure(
+ location=Equals("launchpad.test"),
+ identifier=Equals("craft-recipe-build"),
+ caveats=MatchesListwise(
+ [
+ MatchesStructure.byEquality(
+ caveat_id="lp.craft-recipe-build %s" % build.id
+ ),
+ ]
+ ),
+ ),
+ )
+
+ def test_verifyMacaroon_good(self):
+ build = self.getPrivateBuild()
+ build.updateStatus(BuildStatus.BUILDING)
+ issuer = removeSecurityProxy(
+ getUtility(IMacaroonIssuer, "craft-recipe-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, "craft-recipe-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, "craft-recipe-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, "craft-recipe-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, "craft-recipe-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, "craft-recipe-build")
+ )
+ macaroon = issuer.issueMacaroon(build)
+ self.assertMacaroonDoesNotVerify(
+ ["Caveat check for 'lp.craft-recipe-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, "craft-recipe-build")
+ )
+ macaroon = issuer.issueMacaroon(other_build)
+ self.assertMacaroonDoesNotVerify(
+ [
+ "Caveat check for 'lp.craft-recipe-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, "craft-recipe-build")
+ )
+ macaroon = issuer.issueMacaroon(build)
+ self.assertMacaroonDoesNotVerify(
+ ["Caveat check for 'lp.craft-recipe-build %s' failed." % build.id],
+ issuer,
+ macaroon,
+ other_repository,
+ )
diff --git a/lib/lp/crafts/tests/test_craftrecipebuildbehaviour.py b/lib/lp/crafts/tests/test_craftrecipebuildbehaviour.py
index 803eca1..802977b 100644
--- a/lib/lp/crafts/tests/test_craftrecipebuildbehaviour.py
+++ b/lib/lp/crafts/tests/test_craftrecipebuildbehaviour.py
@@ -16,12 +16,14 @@ from fixtures import MockPatch, TempDir
from pymacaroons import Macaroon
from testtools import ExpectedException
from testtools.matchers import (
+ AfterPreprocessing,
ContainsDict,
Equals,
Is,
IsInstance,
MatchesDict,
MatchesListwise,
+ MatchesStructure,
StartsWith,
)
from testtools.twistedsupport import (
@@ -68,6 +70,7 @@ from lp.crafts.interfaces.craftrecipe import (
)
from lp.crafts.model.craftrecipebuildbehaviour import CraftRecipeBuildBehaviour
from lp.registry.interfaces.series import SeriesStatus
+from lp.services.authserver.testing import InProcessAuthServerFixture
from lp.services.config import config
from lp.services.features.testing import FeatureFixture
from lp.services.log.logger import BufferLogger, DevNullLogger
@@ -197,6 +200,25 @@ class TestAsyncCraftRecipeBuildBehaviour(
self.addCleanup(shut_down_default_process_pool)
self.setUpStats()
+ def assertHasNoZopeSecurityProxy(self, data):
+ """Makes sure that data doesn't contain a security proxy.
+
+ `data` can be a list, a tuple, a dict or an ordinary value. This
+ method checks `data` itself, and if it's a collection, it checks
+ each item in it.
+ """
+ self.assertFalse(
+ isProxy(data), "%s should not be a security proxy." % data
+ )
+ # If it's a collection, keep searching for proxies.
+ if isinstance(data, (list, tuple)):
+ for i in data:
+ self.assertHasNoZopeSecurityProxy(i)
+ elif isinstance(data, dict):
+ for k, v in data.items():
+ self.assertHasNoZopeSecurityProxy(k)
+ self.assertHasNoZopeSecurityProxy(v)
+
def makeJob(self, **kwargs):
# We need a builder in these tests, in order that requesting a proxy
# token can piggyback on its reactor and pool.
@@ -590,6 +612,88 @@ class TestAsyncCraftRecipeBuildBehaviour(
("ensurepresent", chroot_lfa.http_url, "", ""), worker.call_log[0]
)
+ @defer.inlineCallbacks
+ def test_extraBuildArgs_private_git_ref(self):
+ """Test extraBuildArgs for private recipe with git reference."""
+ self.useFixture(InProcessAuthServerFixture())
+ self.pushConfig(
+ "launchpad", internal_macaroon_secret_key="some-secret"
+ )
+ self.useFixture(
+ FeatureFixture(
+ {
+ CRAFT_RECIPE_ALLOW_CREATE: "on",
+ CRAFT_RECIPE_PRIVATE_FEATURE_FLAG: "on",
+ }
+ )
+ )
+
+ # Create public ref first, then transition to private
+ [ref] = self.factory.makeGitRefs()
+ ref.repository.transitionToInformationType(
+ InformationType.USERDATA, ref.repository.owner
+ )
+
+ owner = self.factory.makePerson()
+ recipe = self.factory.makeCraftRecipe(
+ owner=owner,
+ registrant=owner,
+ git_ref=ref,
+ information_type=InformationType.PROPRIETARY,
+ )
+ job = self.makeJob(git_ref=ref, recipe=recipe)
+
+ logger = BufferLogger()
+ with dbuser(config.builddmaster.dbuser):
+ args = yield job.extraBuildArgs(logger=logger)
+
+ # Debug prints
+ print("\nDebug logs:")
+ print(logger.getLogBuffer())
+ print("\nArgs:", args)
+ if "git_repository" in args:
+ print("\nGit URL:", args["git_repository"])
+ parts = urlsplit(args["git_repository"])
+ print("URL parts:", parts)
+
+ # Asserts that nothing here is a zope proxy, to avoid errors when
+ # serializing it for XML-RPC call.
+ self.assertHasNoZopeSecurityProxy(args)
+
+ # Print the log buffer for debugging
+ print("\nDebug logs:")
+ print(logger.getLogBuffer())
+
+ # Add assertions similar to snap build test
+ split_browse_root = urlsplit(config.codehosting.git_browse_root)
+ self.assertThat(
+ args["git_repository"],
+ AfterPreprocessing(
+ urlsplit,
+ MatchesStructure(
+ scheme=Equals(split_browse_root.scheme),
+ username=Equals("+launchpad-services"),
+ password=AfterPreprocessing(
+ Macaroon.deserialize,
+ MatchesStructure(
+ location=Equals(config.vhost.mainsite.hostname),
+ identifier=Equals("craft-recipe-build"),
+ caveats=MatchesListwise(
+ [
+ MatchesStructure.byEquality(
+ caveat_id="lp.craft-recipe-build %s"
+ % job.build.id
+ ),
+ ]
+ ),
+ ),
+ ),
+ hostname=Equals(split_browse_root.hostname),
+ port=Equals(split_browse_root.port),
+ ),
+ ),
+ )
+
class TestAsyncCraftRecipeBuildBehaviourFetchService(
StatsMixin, TestCraftRecipeBuildBehaviourBase
diff --git a/lib/lp/services/authserver/xmlrpc.py b/lib/lp/services/authserver/xmlrpc.py
index 74758fe..23d8625 100644
--- a/lib/lp/services/authserver/xmlrpc.py
+++ b/lib/lp/services/authserver/xmlrpc.py
@@ -16,6 +16,7 @@ from zope.security.proxy import removeSecurityProxy
from lp.app.errors import NotFoundError
from lp.code.interfaces.cibuild import ICIBuildSet
+from lp.crafts.interfaces.craftrecipebuild import ICraftRecipeBuildSet
from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
from lp.registry.interfaces.person import IPersonSet
from lp.services.authserver.interfaces import (
@@ -60,8 +61,8 @@ class AuthServerAPIView(LaunchpadXMLRPCView):
:param context_type: A string identifying the type of context.
Currently only 'LibraryFileAlias', 'BinaryPackageBuild',
- 'LiveFSBuild', 'SnapBuild', 'OCIRecipeBuild', and 'CIBuild' are
- supported.
+ 'LiveFSBuild', 'SnapBuild', 'OCIRecipeBuild', 'CIBuild', and
+ 'CraftRecipeBuild' are supported.
:param context: The context as plain data (e.g. an ID).
:return: The resolved context, or None.
"""
@@ -86,6 +87,9 @@ class AuthServerAPIView(LaunchpadXMLRPCView):
elif context_type == "CIBuild":
# The context is a `CIBuild` ID.
return getUtility(ICIBuildSet).getByID(context)
+ elif context_type == "CraftRecipeBuild":
+ # The context is a `CraftRecipeBuild` ID.
+ return getUtility(ICraftRecipeBuildSet).getByID(context)
else:
return None
Follow ups