launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #21076
[Merge] lp:~cjwatson/launchpad/codeimport-git-auth into lp:launchpad
Colin Watson has proposed merging lp:~cjwatson/launchpad/codeimport-git-auth into lp:launchpad.
Commit message:
Allow pushing to Git repositories associated with running code import jobs using macaroon credentials.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
Related bugs:
Bug #1469459 in Launchpad itself: "import external code into a LP git repo (natively)"
https://bugs.launchpad.net/launchpad/+bug/1469459
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/codeimport-git-auth/+merge/307968
Allow pushing to Git repositories associated with running code import jobs using macaroon credentials.
We don't actually issue suitable macaroons as yet outside the test suite; that will come in a later branch.
I've tried to design the IMacaroonIssuer interface so that it can be useful for some other similar applications (e.g. granting access tokens to builders), but it's provisional and I entirely expect it to need to change when it's actually battle-tested for that kind of thing.
--
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/codeimport-git-auth into lp:launchpad.
=== modified file 'lib/lp/code/configure.zcml'
--- lib/lp/code/configure.zcml 2016-10-03 17:00:56 +0000
+++ lib/lp/code/configure.zcml 2016-10-07 15:56:06 +0000
@@ -729,6 +729,15 @@
reclaimJob"/>
</securedutility>
+ <!-- CodeImportJobMacaroonIssuer -->
+
+ <securedutility
+ class="lp.code.model.codeimportjob.CodeImportJobMacaroonIssuer"
+ provides="lp.services.macaroons.interfaces.IMacaroonIssuer"
+ name="code-import-job">
+ <allow interface="lp.services.macaroons.interfaces.IMacaroonIssuerPublic"/>
+ </securedutility>
+
<!-- CodeImportEvent -->
<class class="lp.code.model.codeimportevent.CodeImportEvent">
=== modified file 'lib/lp/code/model/codeimportjob.py'
--- lib/lp/code/model/codeimportjob.py 2016-10-03 17:00:56 +0000
+++ lib/lp/code/model/codeimportjob.py 2016-10-07 15:56:06 +0000
@@ -12,6 +12,10 @@
import datetime
+from pymacaroons import (
+ Macaroon,
+ Verifier,
+ )
from sqlobject import (
ForeignKey,
IntCol,
@@ -27,7 +31,9 @@
CodeImportMachineState,
CodeImportResultStatus,
CodeImportReviewStatus,
+ GitRepositoryType,
)
+from lp.code.interfaces.codeimport import ICodeImportSet
from lp.code.interfaces.codeimportevent import ICodeImportEventSet
from lp.code.interfaces.codeimportjob import (
ICodeImportJob,
@@ -37,6 +43,7 @@
)
from lp.code.interfaces.codeimportmachine import ICodeImportMachineSet
from lp.code.interfaces.codeimportresult import ICodeImportResultSet
+from lp.code.interfaces.gitrepository import IGitRepository
from lp.code.model.codeimportresult import CodeImportResult
from lp.registry.interfaces.person import validate_public_person
from lp.services.config import config
@@ -48,6 +55,7 @@
SQLBase,
sqlvalues,
)
+from lp.services.macaroons.interfaces import IMacaroonIssuer
@implementer(ICodeImportJob)
@@ -340,3 +348,57 @@
# 4)
getUtility(ICodeImportEventSet).newReclaim(
code_import, machine, job_id)
+
+
+@implementer(IMacaroonIssuer)
+class CodeImportJobMacaroonIssuer:
+
+ @property
+ def _root_secret(self):
+ secret = config.codeimport.macaroon_secret_key
+ if not secret:
+ raise RuntimeError(
+ "codeimport.macaroon_secret_key not configured.")
+ return secret
+
+ def issueMacaroon(self, context):
+ """See `IMacaroonIssuer`."""
+ assert context.code_import.git_repository is not None
+ macaroon = Macaroon(
+ location=config.vhost.mainsite.hostname,
+ identifier="code-import-job", key=self._root_secret)
+ macaroon.add_first_party_caveat("code-import-job %s" % context.id)
+ return macaroon
+
+ def checkMacaroonIssuer(self, macaroon):
+ """See `IMacaroonIssuer`."""
+ try:
+ verifier = Verifier()
+ verifier.satisfy_general(
+ lambda caveat: caveat.startswith("code-import-job "))
+ return verifier.verify(macaroon, self._root_secret)
+ except Exception:
+ return False
+
+ def verifyMacaroon(self, macaroon, context):
+ """See `IMacaroonIssuer`."""
+ if IGitRepository.providedBy(context):
+ if context.repository_type != GitRepositoryType.IMPORTED:
+ return False
+ code_import = getUtility(ICodeImportSet).getByGitRepository(
+ context)
+ if code_import is None:
+ return False
+ job = code_import.import_job
+ if job is None:
+ return False
+ else:
+ job = context
+ try:
+ verifier = Verifier()
+ verifier.satisfy_exact("code-import-job %s" % job.id)
+ return (
+ verifier.verify(macaroon, self._root_secret) and
+ job.state == CodeImportJobState.RUNNING)
+ except Exception:
+ return False
=== modified file 'lib/lp/code/model/tests/test_codeimportjob.py'
--- lib/lp/code/model/tests/test_codeimportjob.py 2015-10-19 10:56:16 +0000
+++ lib/lp/code/model/tests/test_codeimportjob.py 2016-10-07 15:56:06 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
+# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
"""Unit tests for CodeImportJob and CodeImportJobWorkflow."""
@@ -13,7 +13,12 @@
import StringIO
import unittest
+from pymacaroons import Macaroon
from pytz import UTC
+from testtools.matchers import (
+ MatchesListwise,
+ MatchesStructure,
+ )
import transaction
from zope.component import getUtility
from zope.security.proxy import removeSecurityProxy
@@ -23,6 +28,7 @@
CodeImportJobState,
CodeImportResultStatus,
CodeImportReviewStatus,
+ TargetRevisionControlSystems,
)
from lp.code.interfaces.codeimport import ICodeImportSet
from lp.code.interfaces.codeimportevent import ICodeImportEventSet
@@ -41,6 +47,7 @@
from lp.services.database.constants import UTC_NOW
from lp.services.librarian.interfaces import ILibraryFileAliasSet
from lp.services.librarian.interfaces.client import ILibrarianClient
+from lp.services.macaroons.interfaces import IMacaroonIssuer
from lp.services.webapp import canonical_url
from lp.testing import (
ANONYMOUS,
@@ -1144,5 +1151,70 @@
get_feedback_messages(user_browser.contents))
+class TestCodeImportJobMacaroonIssuer(TestCaseWithFactory):
+ """Test CodeImportJob macaroon issuing and verification."""
+
+ layer = DatabaseFunctionalLayer
+
+ def setUp(self):
+ super(TestCodeImportJobMacaroonIssuer, self).setUp()
+ login_for_code_imports()
+ self.pushConfig("codeimport", macaroon_secret_key="some-secret")
+
+ def makeJob(self, target_rcs_type=TargetRevisionControlSystems.GIT):
+ code_import = self.factory.makeCodeImport(
+ target_rcs_type=target_rcs_type)
+ return self.factory.makeCodeImportJob(code_import=code_import)
+
+ def test_issueMacaroon_refuses_branch(self):
+ job = self.makeJob(target_rcs_type=TargetRevisionControlSystems.BZR)
+ issuer = getUtility(IMacaroonIssuer, "code-import-job")
+ self.assertRaises(
+ AssertionError, removeSecurityProxy(issuer).issueMacaroon, job)
+
+ def test_issueMacaroon_good(self):
+ job = self.makeJob()
+ issuer = getUtility(IMacaroonIssuer, "code-import-job")
+ macaroon = removeSecurityProxy(issuer).issueMacaroon(job)
+ self.assertEqual("launchpad.dev", macaroon.location)
+ self.assertEqual("code-import-job", macaroon.identifier)
+ self.assertThat(macaroon.caveats, MatchesListwise([
+ MatchesStructure.byEquality(
+ caveat_id="code-import-job %s" % job.id),
+ ]))
+
+ def test_checkMacaroonIssuer_good(self):
+ job = self.makeJob()
+ issuer = getUtility(IMacaroonIssuer, "code-import-job")
+ macaroon = removeSecurityProxy(issuer).issueMacaroon(job)
+ self.assertTrue(issuer.checkMacaroonIssuer(macaroon))
+
+ def test_checkMacaroonIssuer_wrong_key(self):
+ issuer = getUtility(IMacaroonIssuer, "code-import-job")
+ macaroon = Macaroon(key="another-secret")
+ self.assertFalse(issuer.checkMacaroonIssuer(macaroon))
+
+ def test_verifyMacaroon_good(self):
+ machine = self.factory.makeCodeImportMachine(set_online=True)
+ job = self.makeJob()
+ issuer = getUtility(IMacaroonIssuer, "code-import-job")
+ getUtility(ICodeImportJobWorkflow).startJob(job, machine)
+ macaroon = removeSecurityProxy(issuer).issueMacaroon(job)
+ self.assertTrue(issuer.verifyMacaroon(macaroon, job))
+
+ def test_not_running(self):
+ job = self.makeJob()
+ issuer = getUtility(IMacaroonIssuer, "code-import-job")
+ macaroon = removeSecurityProxy(issuer).issueMacaroon(job)
+ self.assertFalse(issuer.verifyMacaroon(macaroon, job))
+
+ def test_wrong_job(self):
+ job = self.makeJob()
+ other_job = self.factory.makeCodeImportJob(code_import=job.code_import)
+ issuer = getUtility(IMacaroonIssuer, "code-import-job")
+ macaroon = removeSecurityProxy(issuer).issueMacaroon(other_job)
+ self.assertFalse(issuer.verifyMacaroon(macaroon, job))
+
+
def test_suite():
return unittest.TestLoader().loadTestsFromName(__name__)
=== modified file 'lib/lp/code/xmlrpc/git.py'
--- lib/lp/code/xmlrpc/git.py 2016-10-06 20:37:39 +0000
+++ lib/lp/code/xmlrpc/git.py 2016-10-07 15:56:06 +0000
@@ -10,9 +10,13 @@
import sys
+from pymacaroons import Macaroon
from storm.store import Store
import transaction
-from zope.component import getUtility
+from zope.component import (
+ ComponentLookupError,
+ getUtility,
+ )
from zope.error.interfaces import IErrorReportingUtility
from zope.interface import implementer
from zope.security.interfaces import Unauthorized
@@ -53,6 +57,7 @@
NoSuchProduct,
)
from lp.registry.interfaces.sourcepackagename import ISourcePackageNameSet
+from lp.services.macaroons.interfaces import IMacaroonIssuer
from lp.services.webapp import LaunchpadXMLRPCView
from lp.services.webapp.authorization import check_permission
from lp.services.webapp.errorlog import ScriptRequest
@@ -68,22 +73,47 @@
super(GitAPI, self).__init__(*args, **kwargs)
self.repository_set = getUtility(IGitRepositorySet)
- def _performLookup(self, path):
+ def _verifyMacaroon(self, macaroon_raw, repository=None):
+ try:
+ macaroon = Macaroon.deserialize(macaroon_raw)
+ except Exception:
+ return False
+ try:
+ issuer = getUtility(IMacaroonIssuer, macaroon.identifier)
+ except ComponentLookupError:
+ return False
+ if repository is not None:
+ return issuer.verifyMacaroon(macaroon, repository)
+ else:
+ return issuer.checkMacaroonIssuer(macaroon)
+
+ def _performLookup(self, requester, path, auth_params):
repository, extra_path = getUtility(IGitLookup).getByPath(path)
if repository is None:
return None
- try:
- hosting_path = repository.getInternalPath()
- except Unauthorized:
- return None
- writable = (
- repository.repository_type == GitRepositoryType.HOSTED and
- check_permission("launchpad.Edit", repository))
+ macaroon_raw = auth_params.get("macaroon")
+ naked_repository = removeSecurityProxy(repository)
+ if (macaroon_raw is not None and
+ self._verifyMacaroon(macaroon_raw, naked_repository)):
+ # The authentication parameters specifically grant access to
+ # this repository, so we can bypass other checks.
+ hosting_path = naked_repository.getInternalPath()
+ writable = True
+ private = naked_repository.private
+ else:
+ try:
+ hosting_path = repository.getInternalPath()
+ except Unauthorized:
+ return None
+ writable = (
+ repository.repository_type == GitRepositoryType.HOSTED and
+ check_permission("launchpad.Edit", repository))
+ private = repository.private
return {
"path": hosting_path,
"writable": writable,
"trailing": extra_path,
- "private": repository.private,
+ "private": private,
}
def _getGitNamespaceExtras(self, path, requester):
@@ -233,11 +263,11 @@
if requester == LAUNCHPAD_ANONYMOUS:
requester = None
try:
- result = self._performLookup(path)
+ result = self._performLookup(requester, path, auth_params)
if (result is None and requester is not None and
permission == "write"):
self._createRepository(requester, path)
- result = self._performLookup(path)
+ result = self._performLookup(requester, path, auth_params)
if result is None:
raise faults.GitRepositoryNotFound(path)
if permission != "read" and not result["writable"]:
@@ -286,5 +316,10 @@
def authenticateWithPassword(self, username, password):
"""See `IGitAPI`."""
- # Password authentication isn't supported yet.
- return faults.Unauthorized()
+ # XXX cjwatson 2016-10-06: We only support free-floating macaroons
+ # at the moment, not ones bound to a user.
+ if not username and self._verifyMacaroon(password):
+ return {"macaroon": password}
+ else:
+ # Only macaroons are supported for password authentication.
+ return faults.Unauthorized()
=== modified file 'lib/lp/code/xmlrpc/tests/test_git.py'
--- lib/lp/code/xmlrpc/tests/test_git.py 2016-10-06 11:17:05 +0000
+++ lib/lp/code/xmlrpc/tests/test_git.py 2016-10-07 15:56:06 +0000
@@ -5,6 +5,7 @@
__metaclass__ = type
+from pymacaroons import Macaroon
from testscenarios import (
load_tests_apply_scenarios,
WithScenarios,
@@ -13,8 +14,12 @@
from zope.security.proxy import removeSecurityProxy
from lp.app.enums import InformationType
-from lp.code.enums import GitRepositoryType
+from lp.code.enums import (
+ GitRepositoryType,
+ TargetRevisionControlSystems,
+ )
from lp.code.errors import GitRepositoryCreationFault
+from lp.code.interfaces.codeimportjob import ICodeImportJobWorkflow
from lp.code.interfaces.gitcollection import IAllGitRepositories
from lp.code.interfaces.gitjob import IGitRefScanJobSource
from lp.code.interfaces.gitrepository import (
@@ -24,10 +29,12 @@
from lp.code.tests.helpers import GitHostingFixture
from lp.code.xmlrpc.git import GitAPI
from lp.registry.enums import TeamMembershipPolicy
+from lp.services.macaroons.interfaces import IMacaroonIssuer
from lp.services.webapp.escaping import html_escape
from lp.testing import (
admin_logged_in,
ANONYMOUS,
+ celebrity_logged_in,
login,
person_logged_in,
TestCaseWithFactory,
@@ -74,13 +81,15 @@
def assertPermissionDenied(self, requester, path,
message="Permission denied.",
- permission="read", can_authenticate=False):
+ permission="read", can_authenticate=False,
+ macaroon_raw=None):
"""Assert that looking at the given path returns PermissionDenied."""
if requester is not None:
requester = requester.id
- fault = self._translatePath(
- path, permission,
- {"uid": requester, "can-authenticate": can_authenticate})
+ auth_params = {"uid": requester, "can-authenticate": can_authenticate}
+ if macaroon_raw is not None:
+ auth_params["macaroon"] = macaroon_raw
+ fault = self._translatePath(path, permission, auth_params)
self.assertEqual(faults.PermissionDenied(message), fault)
def assertUnauthorized(self, requester, path,
@@ -143,12 +152,13 @@
def assertTranslates(self, requester, path, repository, writable,
permission="read", can_authenticate=False,
- trailing="", private=False):
+ macaroon_raw=None, trailing="", private=False):
if requester is not None:
requester = requester.id
- translation = self._translatePath(
- path, permission,
- {"uid": requester, "can-authenticate": can_authenticate})
+ auth_params = {"uid": requester, "can-authenticate": can_authenticate}
+ if macaroon_raw is not None:
+ auth_params["macaroon"] = macaroon_raw
+ translation = self._translatePath(path, permission, auth_params)
login(ANONYMOUS)
self.assertEqual(
{"path": repository.getInternalPath(), "writable": writable,
@@ -634,6 +644,46 @@
"GitRepositoryCreationFault: nothing here",
self.oopses[0]["tb_text"])
+ def test_translatePath_code_import(self):
+ # A code import worker with a suitable macaroon can write to a
+ # repository associated with a running code import job.
+ self.pushConfig("codeimport", macaroon_secret_key="some-secret")
+ machine = self.factory.makeCodeImportMachine(set_online=True)
+ code_imports = [
+ self.factory.makeCodeImport(
+ target_rcs_type=TargetRevisionControlSystems.GIT)
+ for _ in range(2)]
+ with celebrity_logged_in("vcs_imports"):
+ jobs = [
+ self.factory.makeCodeImportJob(code_import=code_import)
+ for code_import in code_imports]
+ issuer = getUtility(IMacaroonIssuer, "code-import-job")
+ macaroons = [
+ removeSecurityProxy(issuer).issueMacaroon(job) for job in jobs]
+ path = u"/%s" % code_imports[0].git_repository.unique_name
+ self.assertPermissionDenied(
+ None, path, permission="write", macaroon_raw=macaroons[0])
+ with celebrity_logged_in("vcs_imports"):
+ getUtility(ICodeImportJobWorkflow).startJob(jobs[0], machine)
+ # This only works with new-style passing of authentication parameters.
+ if self.auth_params_dict:
+ self.assertTranslates(
+ None, path, code_imports[0].git_repository, True,
+ permission="write", macaroon_raw=macaroons[0].serialize())
+ else:
+ self.assertPermissionDenied(
+ None, path, permission="write",
+ macaroon_raw=macaroons[0].serialize())
+ self.assertPermissionDenied(
+ None, path, permission="write",
+ macaroon_raw=macaroons[1].serialize())
+ self.assertPermissionDenied(
+ None, path, permission="write",
+ macaroon_raw=Macaroon(
+ identifier="another", key="another-secret").serialize())
+ self.assertPermissionDenied(
+ None, path, permission="write", macaroon_raw="nonsense")
+
def test_notify(self):
# The notify call creates a GitRefScanJob.
repository = self.factory.makeGitRepository()
@@ -666,6 +716,26 @@
self.git_api.authenticateWithPassword('foo', 'bar'),
faults.Unauthorized)
+ def test_authenticateWithPassword_code_import(self):
+ self.pushConfig("codeimport", macaroon_secret_key="some-secret")
+ code_import = self.factory.makeCodeImport(
+ target_rcs_type=TargetRevisionControlSystems.GIT)
+ with celebrity_logged_in("vcs_imports"):
+ job = self.factory.makeCodeImportJob(code_import=code_import)
+ issuer = getUtility(IMacaroonIssuer, "code-import-job")
+ macaroon = removeSecurityProxy(issuer).issueMacaroon(job)
+ self.assertEqual(
+ {"macaroon": macaroon.serialize()},
+ self.git_api.authenticateWithPassword("", macaroon.serialize()))
+ other_macaroon = Macaroon(identifier="another", key="another-secret")
+ self.assertIsInstance(
+ self.git_api.authenticateWithPassword(
+ "", other_macaroon.serialize()),
+ faults.Unauthorized)
+ self.assertIsInstance(
+ self.git_api.authenticateWithPassword("", "nonsense"),
+ faults.Unauthorized)
+
class TestGitAPISecurity(TestGitAPIMixin, TestCaseWithFactory):
"""Slow tests for `IGitAPI`.
=== modified file 'lib/lp/services/config/schema-lazr.conf'
--- lib/lp/services/config/schema-lazr.conf 2016-10-04 11:19:07 +0000
+++ lib/lp/services/config/schema-lazr.conf 2016-10-07 15:56:06 +0000
@@ -416,6 +416,9 @@
# Import only this many revisions from svn (via bzr-svn) at once.
svn_revisions_import_limit: 500
+# Secret key for macaroons used to grant git push permission to workers.
+macaroon_secret_key:
+
[codeimportdispatcher]
# The directory where the code import worker should be directed to
# store its logs.
=== added directory 'lib/lp/services/macaroons'
=== added file 'lib/lp/services/macaroons/__init__.py'
=== added file 'lib/lp/services/macaroons/interfaces.py'
--- lib/lp/services/macaroons/interfaces.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/macaroons/interfaces.py 2016-10-07 15:56:06 +0000
@@ -0,0 +1,45 @@
+# Copyright 2016 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Interface to a policy for issuing and verifying macaroons."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'IMacaroonIssuer',
+ ]
+
+from zope.interface import Interface
+
+
+class IMacaroonIssuerPublic(Interface):
+ """Public interface to a policy for verifying macaroons."""
+
+ def checkMacaroonIssuer(macaroon):
+ """Check that `macaroon` was issued by this issuer.
+
+ This does not verify that the macaroon is valid for a given context,
+ only that it could be valid for some context. Use this in the
+ authentication part of an authentication/authorisation API.
+ """
+
+ def verifyMacaroon(macaroon, context):
+ """Verify that `macaroon` is valid for `context`.
+
+ :param macaroon: A `Macaroon`.
+ :param context: The context to check.
+ :return: True if `macaroon` is valid for `context`, otherwise False.
+ """
+
+
+class IMacaroonIssuer(IMacaroonIssuerPublic):
+ """Interface to a policy for issuing and verifying macaroons."""
+
+ def issueMacaroon(context):
+ """Issue a macaroon for `context`.
+
+ :param context: The context that the returned macaroon should relate
+ to.
+ :return: A macaroon.
+ """
Follow ups