← Back to team overview

launchpad-reviewers team mailing list archive

[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