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