Merge lp:~cjwatson/launchpad/authserver-issue-macaroon into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 18943
Proposed branch: lp:~cjwatson/launchpad/authserver-issue-macaroon
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/snap-build-macaroon
Diff against target: 479 lines (+176/-28)
9 files modified
lib/lp/code/model/tests/test_codeimportjob.py (+12/-0)
lib/lp/services/authserver/interfaces.py (+17/-2)
lib/lp/services/authserver/tests/test_authserver.py (+49/-10)
lib/lp/services/authserver/xmlrpc.py (+56/-9)
lib/lp/services/macaroons/interfaces.py (+5/-1)
lib/lp/services/macaroons/model.py (+2/-0)
lib/lp/snappy/model/snapbuild.py (+3/-6)
lib/lp/snappy/tests/test_snapbuild.py (+18/-0)
lib/lp/soyuz/tests/test_binarypackagebuild.py (+14/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/authserver-issue-macaroon
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+364353@code.launchpad.net

Commit message

Add an authserver method to issue macaroons (currently only snap-build).

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/code/model/tests/test_codeimportjob.py'
2--- lib/lp/code/model/tests/test_codeimportjob.py 2019-04-24 12:50:20 +0000
3+++ lib/lp/code/model/tests/test_codeimportjob.py 2019-04-25 09:56:06 +0000
4@@ -25,6 +25,7 @@
5 )
6 import transaction
7 from zope.component import getUtility
8+from zope.publisher.xmlrpc import TestRequest
9 from zope.security.proxy import removeSecurityProxy
10
11 from lp.app.enums import InformationType
12@@ -53,6 +54,7 @@
13 make_running_import,
14 )
15 from lp.code.tests.helpers import GitHostingFixture
16+from lp.services.authserver.xmlrpc import AuthServerAPIView
17 from lp.services.config import config
18 from lp.services.database.constants import UTC_NOW
19 from lp.services.database.interfaces import IStore
20@@ -78,6 +80,8 @@
21 LaunchpadFunctionalLayer,
22 )
23 from lp.testing.pages import get_feedback_messages
24+from lp.xmlrpc import faults
25+from lp.xmlrpc.interfaces import IPrivateApplication
26
27
28 def login_for_code_imports():
29@@ -1306,6 +1310,14 @@
30 self.pushConfig("codeimport", macaroon_secret_key="some-secret")
31 self.test_issueMacaroon_good()
32
33+ def test_issueMacaroon_not_via_authserver(self):
34+ job = self.makeJob()
35+ private_root = getUtility(IPrivateApplication)
36+ authserver = AuthServerAPIView(private_root.authserver, TestRequest())
37+ self.assertEqual(
38+ faults.PermissionDenied(),
39+ authserver.issueMacaroon("code-import-job", "CodeImportJob", job))
40+
41 def test_verifyMacaroon_good(self):
42 machine = self.factory.makeCodeImportMachine(set_online=True)
43 job = self.makeJob()
44
45=== modified file 'lib/lp/services/authserver/interfaces.py'
46--- lib/lp/services/authserver/interfaces.py 2019-04-23 13:50:35 +0000
47+++ lib/lp/services/authserver/interfaces.py 2019-04-25 09:56:06 +0000
48@@ -1,4 +1,4 @@
49-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
50+# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
51 # GNU Affero General Public License version 3 (see the file LICENSE).
52
53 """Interface for the XML-RPC authentication server."""
54@@ -27,12 +27,27 @@
55 person with the given name.
56 """
57
58+ def issueMacaroon(issuer_name, context_type, context):
59+ """Issue a macaroon of type `issuer_name` for `context`.
60+
61+ :param issuer_name: An `IMacaroonIssuer` name. Only issuers where
62+ `issuable_via_authserver` is True are permitted.
63+ :param context_type: A string identifying the type of context for
64+ which to issue the macaroon. Currently only 'LibraryFileAlias'
65+ and 'SnapBuild' are supported.
66+ :param context: The context for which to issue the macaroon. Note
67+ that this is passed over XML-RPC, so it should be plain data
68+ (e.g. an ID) rather than a database object.
69+ :return: A serialised macaroon or a fault.
70+ """
71+
72 def verifyMacaroon(macaroon_raw, context_type, context):
73 """Verify that `macaroon_raw` grants access to `context`.
74
75 :param macaroon_raw: A serialised macaroon.
76 :param context_type: A string identifying the type of context to
77- check. Currently only 'LibraryFileAlias' is supported.
78+ check. Currently only 'LibraryFileAlias' and 'SnapBuild' are
79+ supported.
80 :param context: The context to check. Note that this is passed over
81 XML-RPC, so it should be plain data (e.g. an ID) rather than a
82 database object.
83
84=== modified file 'lib/lp/services/authserver/tests/test_authserver.py'
85--- lib/lp/services/authserver/tests/test_authserver.py 2019-04-24 12:50:20 +0000
86+++ lib/lp/services/authserver/tests/test_authserver.py 2019-04-25 09:56:06 +0000
87@@ -7,7 +7,12 @@
88
89 from pymacaroons import Macaroon
90 from storm.sqlobject import SQLObjectNotFound
91-from testtools.matchers import Is
92+from testtools.matchers import (
93+ Equals,
94+ Is,
95+ MatchesListwise,
96+ MatchesStructure,
97+ )
98 from zope.component import getUtility
99 from zope.interface import implementer
100 from zope.publisher.xmlrpc import TestRequest
101@@ -25,7 +30,6 @@
102 from lp.services.macaroons.model import MacaroonIssuerBase
103 from lp.testing import (
104 person_logged_in,
105- TestCase,
106 TestCaseWithFactory,
107 )
108 from lp.testing.fixture import ZopeUtilityFixture
109@@ -82,6 +86,7 @@
110 class DummyMacaroonIssuer(MacaroonIssuerBase):
111
112 identifier = 'test'
113+ issuable_via_authserver = True
114 _root_secret = 'test'
115
116 def checkIssuingContext(self, context):
117@@ -101,12 +106,12 @@
118 return caveat_value == str(context.id)
119
120
121-class VerifyMacaroonTests(TestCase):
122+class MacaroonTests(TestCaseWithFactory):
123
124 layer = ZopelessDatabaseLayer
125
126 def setUp(self):
127- super(VerifyMacaroonTests, self).setUp()
128+ super(MacaroonTests, self).setUp()
129 self.issuer = DummyMacaroonIssuer()
130 self.useFixture(ZopeUtilityFixture(
131 self.issuer, IMacaroonIssuer, name='test'))
132@@ -114,12 +119,46 @@
133 self.authserver = AuthServerAPIView(
134 private_root.authserver, TestRequest())
135
136- def test_nonsense_macaroon(self):
137+ def test_issue_unknown_issuer(self):
138+ self.assertEqual(
139+ faults.PermissionDenied(),
140+ self.authserver.issueMacaroon(
141+ 'unknown-issuer', 'LibraryFileAlias', 1))
142+
143+ def test_issue_wrong_context_type(self):
144+ self.assertEqual(
145+ faults.PermissionDenied(),
146+ self.authserver.issueMacaroon(
147+ 'unknown-issuer', 'nonsense', 1))
148+
149+ def test_issue_not_issuable_via_authserver(self):
150+ self.issuer.issuable_via_authserver = False
151+ self.assertEqual(
152+ faults.PermissionDenied(),
153+ self.authserver.issueMacaroon('test', 'LibraryFileAlias', 1))
154+
155+ def test_issue_bad_context(self):
156+ build = self.factory.makeSnapBuild()
157+ self.assertEqual(
158+ faults.PermissionDenied(),
159+ self.authserver.issueMacaroon('test', 'SnapBuild', build.id))
160+
161+ def test_issue_success(self):
162+ macaroon = Macaroon.deserialize(
163+ self.authserver.issueMacaroon('test', 'LibraryFileAlias', 1))
164+ self.assertThat(macaroon, MatchesStructure(
165+ location=Equals(config.vhost.mainsite.hostname),
166+ identifier=Equals('test'),
167+ caveats=MatchesListwise([
168+ MatchesStructure.byEquality(caveat_id='lp.test 1'),
169+ ])))
170+
171+ def test_verify_nonsense_macaroon(self):
172 self.assertEqual(
173 faults.Unauthorized(),
174 self.authserver.verifyMacaroon('nonsense', 'LibraryFileAlias', 1))
175
176- def test_unknown_issuer(self):
177+ def test_verify_unknown_issuer(self):
178 macaroon = Macaroon(
179 location=config.vhost.mainsite.hostname,
180 identifier='unknown-issuer', key='test')
181@@ -128,7 +167,7 @@
182 self.authserver.verifyMacaroon(
183 macaroon.serialize(), 'LibraryFileAlias', 1))
184
185- def test_wrong_context_type(self):
186+ def test_verify_wrong_context_type(self):
187 lfa = getUtility(ILibraryFileAliasSet)[1]
188 macaroon = self.issuer.issueMacaroon(lfa)
189 self.assertEqual(
190@@ -136,7 +175,7 @@
191 self.authserver.verifyMacaroon(
192 macaroon.serialize(), 'nonsense', lfa.id))
193
194- def test_wrong_context(self):
195+ def test_verify_wrong_context(self):
196 lfa = getUtility(ILibraryFileAliasSet)[1]
197 macaroon = self.issuer.issueMacaroon(lfa)
198 self.assertEqual(
199@@ -144,7 +183,7 @@
200 self.authserver.verifyMacaroon(
201 macaroon.serialize(), 'LibraryFileAlias', 2))
202
203- def test_nonexistent_lfa(self):
204+ def test_verify_nonexistent_lfa(self):
205 macaroon = self.issuer.issueMacaroon(
206 getUtility(ILibraryFileAliasSet)[1])
207 # Pick a large ID that doesn't exist in sampledata.
208@@ -157,7 +196,7 @@
209 self.authserver.verifyMacaroon(
210 macaroon.serialize(), 'LibraryFileAlias', lfa_id))
211
212- def test_success(self):
213+ def test_verify_success(self):
214 lfa = getUtility(ILibraryFileAliasSet)[1]
215 macaroon = self.issuer.issueMacaroon(lfa)
216 self.assertThat(
217
218=== modified file 'lib/lp/services/authserver/xmlrpc.py'
219--- lib/lp/services/authserver/xmlrpc.py 2019-04-23 13:50:35 +0000
220+++ lib/lp/services/authserver/xmlrpc.py 2019-04-25 09:56:06 +0000
221@@ -17,6 +17,7 @@
222 getUtility,
223 )
224 from zope.interface import implementer
225+from zope.security.proxy import removeSecurityProxy
226
227 from lp.registry.interfaces.person import IPersonSet
228 from lp.services.authserver.interfaces import (
229@@ -24,8 +25,12 @@
230 IAuthServerApplication,
231 )
232 from lp.services.librarian.interfaces import ILibraryFileAliasSet
233-from lp.services.macaroons.interfaces import IMacaroonIssuer
234+from lp.services.macaroons.interfaces import (
235+ BadMacaroonContext,
236+ IMacaroonIssuer,
237+ )
238 from lp.services.webapp import LaunchpadXMLRPCView
239+from lp.snappy.interfaces.snapbuild import ISnapBuildSet
240 from lp.xmlrpc import faults
241
242
243@@ -45,6 +50,53 @@
244 for key in person.sshkeys],
245 }
246
247+ def _resolveContext(self, context_type, context):
248+ """Resolve a serialised context.
249+
250+ :param context_type: A string identifying the type of context.
251+ Currently only 'LibraryFileAlias' and 'SnapBuild' are supported.
252+ :param context: The context as plain data (e.g. an ID).
253+ :return: The resolved context, or None.
254+ """
255+ if context_type == 'LibraryFileAlias':
256+ # The context is a `LibraryFileAlias` ID.
257+ try:
258+ return getUtility(ILibraryFileAliasSet)[context]
259+ except SQLObjectNotFound:
260+ return None
261+ elif context_type == 'SnapBuild':
262+ # The context is a `SnapBuild` ID.
263+ return getUtility(ISnapBuildSet).getByID(context)
264+ else:
265+ return None
266+
267+ def issueMacaroon(self, issuer_name, context_type, context):
268+ """See `IAuthServer.issueMacaroon`."""
269+ try:
270+ issuer = getUtility(IMacaroonIssuer, issuer_name)
271+ except ComponentLookupError:
272+ return faults.PermissionDenied()
273+ # Only permit issuers that have been specifically designed for use
274+ # with the authserver: they must need to be issued by parts of
275+ # Launchpad other than appservers but be verified by appservers,
276+ # they must take parameters that can be passed over XML-RPC, and
277+ # they must issue macaroons with carefully-designed constraints to
278+ # minimise privilege-escalation attacks.
279+ if not issuer.issuable_via_authserver:
280+ return faults.PermissionDenied()
281+ # The context is plain data, since we can't pass general objects over
282+ # the XML-RPC interface. Look it up so that we can verify it.
283+ context = self._resolveContext(context_type, context)
284+ if context is None:
285+ return faults.PermissionDenied()
286+ try:
287+ # issueMacaroon isn't normally public, but we clearly need it
288+ # here.
289+ macaroon = removeSecurityProxy(issuer).issueMacaroon(context)
290+ except BadMacaroonContext:
291+ return faults.PermissionDenied()
292+ return macaroon.serialize()
293+
294 def verifyMacaroon(self, macaroon_raw, context_type, context):
295 """See `IAuthServer.verifyMacaroon`."""
296 try:
297@@ -59,15 +111,10 @@
298 return faults.Unauthorized()
299 # The context is plain data, since we can't pass general objects over
300 # the XML-RPC interface. Look it up so that we can verify it.
301- if context_type == 'LibraryFileAlias':
302- # The context is a `LibraryFileAlias` ID.
303- try:
304- lfa = getUtility(ILibraryFileAliasSet)[context]
305- except SQLObjectNotFound:
306- return faults.Unauthorized()
307- else:
308+ context = self._resolveContext(context_type, context)
309+ if context is None:
310 return faults.Unauthorized()
311- if not issuer.verifyMacaroon(macaroon, lfa):
312+ if not issuer.verifyMacaroon(macaroon, context):
313 return faults.Unauthorized()
314 return True
315
316
317=== modified file 'lib/lp/services/macaroons/interfaces.py'
318--- lib/lp/services/macaroons/interfaces.py 2019-04-24 12:50:20 +0000
319+++ lib/lp/services/macaroons/interfaces.py 2019-04-25 09:56:06 +0000
320@@ -12,6 +12,7 @@
321 ]
322
323 from zope.interface import Interface
324+from zope.schema import Bool
325
326
327 class BadMacaroonContext(Exception):
328@@ -27,6 +28,9 @@
329 class IMacaroonIssuerPublic(Interface):
330 """Public interface to a policy for verifying macaroons."""
331
332+ issuable_via_authserver = Bool(
333+ "Does this issuer allow issuing macaroons via the authserver?")
334+
335 def verifyMacaroon(macaroon, context, require_context=True):
336 """Verify that `macaroon` is valid for `context`.
337
338@@ -49,6 +53,6 @@
339
340 :param context: The context that the returned macaroon should relate
341 to.
342- :raises ValueError: if the context is unsuitable.
343+ :raises BadMacaroonContext: if the context is unsuitable.
344 :return: A macaroon.
345 """
346
347=== modified file 'lib/lp/services/macaroons/model.py'
348--- lib/lp/services/macaroons/model.py 2019-04-24 16:15:47 +0000
349+++ lib/lp/services/macaroons/model.py 2019-04-25 09:56:06 +0000
350@@ -23,6 +23,8 @@
351 class MacaroonIssuerBase:
352 """See `IMacaroonIssuer`."""
353
354+ issuable_via_authserver = False
355+
356 @property
357 def identifier(self):
358 """An identifying name for this issuer."""
359
360=== modified file 'lib/lp/snappy/model/snapbuild.py'
361--- lib/lp/snappy/model/snapbuild.py 2019-04-24 12:50:20 +0000
362+++ lib/lp/snappy/model/snapbuild.py 2019-04-25 09:56:06 +0000
363@@ -597,17 +597,14 @@
364 class SnapBuildMacaroonIssuer(MacaroonIssuerBase):
365
366 identifier = "snap-build"
367+ issuable_via_authserver = True
368
369 def checkIssuingContext(self, context):
370 """See `MacaroonIssuerBase`.
371
372- For issuing, the context is an `ISnapBuild` or its ID.
373+ For issuing, the context is an `ISnapBuild`.
374 """
375- if ISnapBuild.providedBy(context):
376- pass
377- elif isinstance(context, int):
378- context = getUtility(ISnapBuildSet).getByID(context)
379- else:
380+ if not ISnapBuild.providedBy(context):
381 raise BadMacaroonContext(context)
382 if not removeSecurityProxy(context).is_private:
383 raise BadMacaroonContext(
384
385=== modified file 'lib/lp/snappy/tests/test_snapbuild.py'
386--- lib/lp/snappy/tests/test_snapbuild.py 2019-04-24 12:50:20 +0000
387+++ lib/lp/snappy/tests/test_snapbuild.py 2019-04-25 09:56:06 +0000
388@@ -26,6 +26,7 @@
389 MatchesStructure,
390 )
391 from zope.component import getUtility
392+from zope.publisher.xmlrpc import TestRequest
393 from zope.security.proxy import removeSecurityProxy
394
395 from lp.app.enums import InformationType
396@@ -36,6 +37,7 @@
397 from lp.buildmaster.interfaces.packagebuild import IPackageBuild
398 from lp.buildmaster.interfaces.processor import IProcessorSet
399 from lp.registry.enums import PersonVisibility
400+from lp.services.authserver.xmlrpc import AuthServerAPIView
401 from lp.services.config import config
402 from lp.services.features.testing import FeatureFixture
403 from lp.services.job.interfaces.job import JobStatus
404@@ -73,6 +75,7 @@
405 from lp.testing.mail_helpers import pop_notifications
406 from lp.testing.matchers import HasQueryCount
407 from lp.testing.pages import webservice_for_person
408+from lp.xmlrpc.interfaces import IPrivateApplication
409
410
411 expected_body = """\
412@@ -813,6 +816,21 @@
413 caveat_id="lp.snap-build %s" % build.id),
414 ])))
415
416+ def test_issueMacaroon_via_authserver(self):
417+ build = self.factory.makeSnapBuild(
418+ snap=self.factory.makeSnap(private=True))
419+ private_root = getUtility(IPrivateApplication)
420+ authserver = AuthServerAPIView(private_root.authserver, TestRequest())
421+ macaroon = Macaroon.deserialize(
422+ authserver.issueMacaroon("snap-build", "SnapBuild", build.id))
423+ self.assertThat(macaroon, MatchesStructure(
424+ location=Equals("launchpad.dev"),
425+ identifier=Equals("snap-build"),
426+ caveats=MatchesListwise([
427+ MatchesStructure.byEquality(
428+ caveat_id="lp.snap-build %s" % build.id),
429+ ])))
430+
431 def test_verifyMacaroon_good(self):
432 [ref] = self.factory.makeGitRefs(
433 information_type=InformationType.USERDATA)
434
435=== modified file 'lib/lp/soyuz/tests/test_binarypackagebuild.py'
436--- lib/lp/soyuz/tests/test_binarypackagebuild.py 2019-04-24 12:50:20 +0000
437+++ lib/lp/soyuz/tests/test_binarypackagebuild.py 2019-04-25 09:56:06 +0000
438@@ -18,6 +18,7 @@
439 MatchesStructure,
440 )
441 from zope.component import getUtility
442+from zope.publisher.xmlrpc import TestRequest
443 from zope.security.proxy import removeSecurityProxy
444
445 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
446@@ -27,6 +28,7 @@
447 from lp.registry.interfaces.pocket import PackagePublishingPocket
448 from lp.registry.interfaces.series import SeriesStatus
449 from lp.registry.interfaces.sourcepackage import SourcePackageUrgency
450+from lp.services.authserver.xmlrpc import AuthServerAPIView
451 from lp.services.config import config
452 from lp.services.log.logger import DevNullLogger
453 from lp.services.macaroons.interfaces import (
454@@ -67,6 +69,8 @@
455 LaunchpadZopelessLayer,
456 )
457 from lp.testing.pages import webservice_for_person
458+from lp.xmlrpc import faults
459+from lp.xmlrpc.interfaces import IPrivateApplication
460
461
462 class TestBinaryPackageBuild(TestCaseWithFactory):
463@@ -938,6 +942,16 @@
464 caveat_id="lp.principal.binary-package-build %s" % build.id),
465 ]))
466
467+ def test_issueMacaroon_not_via_authserver(self):
468+ build = self.factory.makeBinaryPackageBuild(
469+ archive=self.factory.makeArchive(private=True))
470+ private_root = getUtility(IPrivateApplication)
471+ authserver = AuthServerAPIView(private_root.authserver, TestRequest())
472+ self.assertEqual(
473+ faults.PermissionDenied(),
474+ authserver.issueMacaroon(
475+ "binary-package-build", "BinaryPackageBuild", build))
476+
477 def test_verifyMacaroon_good(self):
478 build = self.factory.makeBinaryPackageBuild(
479 archive=self.factory.makeArchive(private=True))