Merge lp:~cjwatson/launchpad/snap-build-macaroon into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 18942
Proposed branch: lp:~cjwatson/launchpad/snap-build-macaroon
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/librarian-accept-macaroon
Diff against target: 977 lines (+469/-210)
11 files modified
lib/lp/code/model/codeimportjob.py (+30/-42)
lib/lp/code/model/tests/test_codeimportjob.py (+27/-22)
lib/lp/code/xmlrpc/git.py (+2/-2)
lib/lp/services/authserver/tests/test_authserver.py (+23/-38)
lib/lp/services/macaroons/interfaces.py (+19/-10)
lib/lp/services/macaroons/model.py (+132/-0)
lib/lp/snappy/configure.zcml (+8/-0)
lib/lp/snappy/model/snapbuild.py (+58/-1)
lib/lp/snappy/tests/test_snapbuild.py (+121/-1)
lib/lp/soyuz/model/binarypackagebuild.py (+43/-72)
lib/lp/soyuz/tests/test_binarypackagebuild.py (+6/-22)
To merge this branch: bzr merge lp:~cjwatson/launchpad/snap-build-macaroon
Reviewer Review Type Date Requested Status
William Grant (community) code Approve
Review via email: mp+364333@code.launchpad.net

Commit message

Add a snap-build macaroon issuer.

Description of the change

I factored out some common code into a base class, since there are now three rather similar implementations.

Nothing uses this yet, but I'm planning for SnapBuildBehaviour to do so via an extension to the authserver.

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

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/code/model/codeimportjob.py'
2--- lib/lp/code/model/codeimportjob.py 2018-11-01 18:03:06 +0000
3+++ lib/lp/code/model/codeimportjob.py 2019-04-24 16:19:42 +0000
4@@ -1,4 +1,4 @@
5-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
6+# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
7 # GNU Affero General Public License version 3 (see the file LICENSE).
8
9 """Database classes for the CodeImportJob table."""
10@@ -12,10 +12,6 @@
11
12 import datetime
13
14-from pymacaroons import (
15- Macaroon,
16- Verifier,
17- )
18 from sqlobject import (
19 ForeignKey,
20 IntCol,
21@@ -58,7 +54,11 @@
22 SQLBase,
23 sqlvalues,
24 )
25-from lp.services.macaroons.interfaces import IMacaroonIssuer
26+from lp.services.macaroons.interfaces import (
27+ BadMacaroonContext,
28+ IMacaroonIssuer,
29+ )
30+from lp.services.macaroons.model import MacaroonIssuerBase
31
32
33 @implementer(ICodeImportJob)
34@@ -409,7 +409,9 @@
35
36
37 @implementer(IMacaroonIssuer)
38-class CodeImportJobMacaroonIssuer:
39+class CodeImportJobMacaroonIssuer(MacaroonIssuerBase):
40+
41+ identifier = "code-import-job"
42
43 @property
44 def _root_secret(self):
45@@ -423,38 +425,24 @@
46 "launchpad.internal_macaroon_secret_key not configured.")
47 return secret
48
49- def issueMacaroon(self, context):
50- """See `IMacaroonIssuer`."""
51- assert context.code_import.git_repository is not None
52- macaroon = Macaroon(
53- location=config.vhost.mainsite.hostname,
54- identifier="code-import-job", key=self._root_secret)
55- macaroon.add_first_party_caveat("lp.code-import-job %s" % context.id)
56- return macaroon
57-
58- def checkMacaroonIssuer(self, macaroon):
59- """See `IMacaroonIssuer`."""
60- if macaroon.location != config.vhost.mainsite.hostname:
61- return False
62- try:
63- verifier = Verifier()
64- verifier.satisfy_general(
65- lambda caveat: caveat.startswith("lp.code-import-job "))
66- return verifier.verify(macaroon, self._root_secret)
67- except Exception:
68- return False
69-
70- def verifyMacaroon(self, macaroon, context):
71- """See `IMacaroonIssuer`."""
72- if not ICodeImportJob.providedBy(context):
73- return False
74- if not self.checkMacaroonIssuer(macaroon):
75- return False
76- try:
77- verifier = Verifier()
78- verifier.satisfy_exact("lp.code-import-job %s" % context.id)
79- return (
80- verifier.verify(macaroon, self._root_secret) and
81- context.state == CodeImportJobState.RUNNING)
82- except Exception:
83- return False
84+ def checkIssuingContext(self, context):
85+ """See `MacaroonIssuerBase`."""
86+ if context.code_import.git_repository is None:
87+ raise BadMacaroonContext(
88+ context, "context.code_import.git_repository is None")
89+ return context.id
90+
91+ def checkVerificationContext(self, context):
92+ """See `MacaroonIssuerBase`."""
93+ if (not ICodeImportJob.providedBy(context) or
94+ context.state != CodeImportJobState.RUNNING):
95+ raise BadMacaroonContext(context)
96+ return context
97+
98+ def verifyPrimaryCaveat(self, caveat_value, context):
99+ """See `MacaroonIssuerBase`."""
100+ if context is None:
101+ # We're only verifying that the macaroon could be valid for some
102+ # context.
103+ return True
104+ return caveat_value == str(context.id)
105
106=== modified file 'lib/lp/code/model/tests/test_codeimportjob.py'
107--- lib/lp/code/model/tests/test_codeimportjob.py 2018-11-01 18:03:06 +0000
108+++ lib/lp/code/model/tests/test_codeimportjob.py 2019-04-24 16:19:42 +0000
109@@ -1,4 +1,4 @@
110-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
111+# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
112 # GNU Affero General Public License version 3 (see the file LICENSE).
113
114 """Unit tests for CodeImportJob and CodeImportJobWorkflow."""
115@@ -59,7 +59,10 @@
116 from lp.services.database.sqlbase import get_transaction_timestamp
117 from lp.services.librarian.interfaces import ILibraryFileAliasSet
118 from lp.services.librarian.interfaces.client import ILibrarianClient
119-from lp.services.macaroons.interfaces import IMacaroonIssuer
120+from lp.services.macaroons.interfaces import (
121+ BadMacaroonContext,
122+ IMacaroonIssuer,
123+ )
124 from lp.services.webapp import canonical_url
125 from lp.testing import (
126 ANONYMOUS,
127@@ -1285,7 +1288,7 @@
128 job = self.makeJob(target_rcs_type=TargetRevisionControlSystems.BZR)
129 issuer = getUtility(IMacaroonIssuer, "code-import-job")
130 self.assertRaises(
131- AssertionError, removeSecurityProxy(issuer).issueMacaroon, job)
132+ BadMacaroonContext, removeSecurityProxy(issuer).issueMacaroon, job)
133
134 def test_issueMacaroon_good(self):
135 job = self.makeJob()
136@@ -1303,25 +1306,6 @@
137 self.pushConfig("codeimport", macaroon_secret_key="some-secret")
138 self.test_issueMacaroon_good()
139
140- def test_checkMacaroonIssuer_good(self):
141- job = self.makeJob()
142- issuer = getUtility(IMacaroonIssuer, "code-import-job")
143- macaroon = removeSecurityProxy(issuer).issueMacaroon(job)
144- self.assertTrue(issuer.checkMacaroonIssuer(macaroon))
145-
146- def test_checkMacaroonIssuer_wrong_location(self):
147- issuer = getUtility(IMacaroonIssuer, "code-import-job")
148- macaroon = Macaroon(
149- location="another-location",
150- key=removeSecurityProxy(issuer)._root_secret)
151- self.assertFalse(issuer.checkMacaroonIssuer(macaroon))
152-
153- def test_checkMacaroonIssuer_wrong_key(self):
154- issuer = getUtility(IMacaroonIssuer, "code-import-job")
155- macaroon = Macaroon(
156- location=config.vhost.mainsite.hostname, key="another-secret")
157- self.assertFalse(issuer.checkMacaroonIssuer(macaroon))
158-
159 def test_verifyMacaroon_good(self):
160 machine = self.factory.makeCodeImportMachine(set_online=True)
161 job = self.makeJob()
162@@ -1330,6 +1314,23 @@
163 macaroon = removeSecurityProxy(issuer).issueMacaroon(job)
164 self.assertTrue(issuer.verifyMacaroon(macaroon, job))
165
166+ def test_verifyMacaroon_good_no_context(self):
167+ machine = self.factory.makeCodeImportMachine(set_online=True)
168+ job = self.makeJob()
169+ issuer = getUtility(IMacaroonIssuer, "code-import-job")
170+ getUtility(ICodeImportJobWorkflow).startJob(job, machine)
171+ macaroon = removeSecurityProxy(issuer).issueMacaroon(job)
172+ self.assertTrue(
173+ issuer.verifyMacaroon(macaroon, None, require_context=False))
174+
175+ def test_verifyMacaroon_no_context_but_require_context(self):
176+ machine = self.factory.makeCodeImportMachine(set_online=True)
177+ job = self.makeJob()
178+ issuer = getUtility(IMacaroonIssuer, "code-import-job")
179+ getUtility(ICodeImportJobWorkflow).startJob(job, machine)
180+ macaroon = removeSecurityProxy(issuer).issueMacaroon(job)
181+ self.assertFalse(issuer.verifyMacaroon(macaroon, None))
182+
183 def test_verifyMacaroon_wrong_location(self):
184 machine = self.factory.makeCodeImportMachine(set_online=True)
185 job = self.makeJob()
186@@ -1339,6 +1340,8 @@
187 location="another-location",
188 key=removeSecurityProxy(issuer)._root_secret)
189 self.assertFalse(issuer.verifyMacaroon(macaroon, job))
190+ self.assertFalse(
191+ issuer.verifyMacaroon(macaroon, None, require_context=False))
192
193 def test_verifyMacaroon_wrong_key(self):
194 machine = self.factory.makeCodeImportMachine(set_online=True)
195@@ -1348,6 +1351,8 @@
196 macaroon = Macaroon(
197 location=config.vhost.mainsite.hostname, key="another-secret")
198 self.assertFalse(issuer.verifyMacaroon(macaroon, job))
199+ self.assertFalse(
200+ issuer.verifyMacaroon(macaroon, None, require_context=False))
201
202 def test_verifyMacaroon_not_running(self):
203 job = self.makeJob()
204
205=== modified file 'lib/lp/code/xmlrpc/git.py'
206--- lib/lp/code/xmlrpc/git.py 2019-04-23 12:30:16 +0000
207+++ lib/lp/code/xmlrpc/git.py 2019-04-24 16:19:42 +0000
208@@ -101,9 +101,9 @@
209 job = code_import.import_job
210 if job is None:
211 return False
212- return issuer.verifyMacaroon(macaroon, job)
213 else:
214- return issuer.checkMacaroonIssuer(macaroon)
215+ job = None
216+ return issuer.verifyMacaroon(macaroon, job, require_context=False)
217
218 def _performLookup(self, requester, path, auth_params):
219 repository, extra_path = getUtility(IGitLookup).getByPath(path)
220
221=== modified file 'lib/lp/services/authserver/tests/test_authserver.py'
222--- lib/lp/services/authserver/tests/test_authserver.py 2019-04-23 13:50:35 +0000
223+++ lib/lp/services/authserver/tests/test_authserver.py 2019-04-24 16:19:42 +0000
224@@ -5,10 +5,7 @@
225
226 __metaclass__ = type
227
228-from pymacaroons import (
229- Macaroon,
230- Verifier,
231- )
232+from pymacaroons import Macaroon
233 from storm.sqlobject import SQLObjectNotFound
234 from testtools.matchers import Is
235 from zope.component import getUtility
236@@ -21,7 +18,11 @@
237 ILibraryFileAlias,
238 ILibraryFileAliasSet,
239 )
240-from lp.services.macaroons.interfaces import IMacaroonIssuer
241+from lp.services.macaroons.interfaces import (
242+ BadMacaroonContext,
243+ IMacaroonIssuer,
244+ )
245+from lp.services.macaroons.model import MacaroonIssuerBase
246 from lp.testing import (
247 person_logged_in,
248 TestCase,
249@@ -78,42 +79,26 @@
250
251
252 @implementer(IMacaroonIssuer)
253-class DummyMacaroonIssuer:
254+class DummyMacaroonIssuer(MacaroonIssuerBase):
255
256+ identifier = 'test'
257 _root_secret = 'test'
258
259- def issueMacaroon(self, context):
260- """See `IMacaroonIssuer`."""
261- macaroon = Macaroon(
262- location=config.vhost.mainsite.hostname, identifier='test',
263- key=self._root_secret)
264- macaroon.add_first_party_caveat('test %s' % context.id)
265- return macaroon
266-
267- def checkMacaroonIssuer(self, macaroon):
268- """See `IMacaroonIssuer`."""
269- if macaroon.location != config.vhost.mainsite.hostname:
270- return False
271- try:
272- verifier = Verifier()
273- verifier.satisfy_general(
274- lambda caveat: caveat.startswith('test '))
275- return verifier.verify(macaroon, self._root_secret)
276- except Exception:
277- return False
278-
279- def verifyMacaroon(self, macaroon, context):
280- """See `IMacaroonIssuer`."""
281- if not ILibraryFileAlias.providedBy(context):
282- return False
283- if not self.checkMacaroonIssuer(macaroon):
284- return False
285- try:
286- verifier = Verifier()
287- verifier.satisfy_exact('test %s' % context.id)
288- return verifier.verify(macaroon, self._root_secret)
289- except Exception:
290- return False
291+ def checkIssuingContext(self, context):
292+ """See `MacaroonIssuerBase`."""
293+ if not ILibraryFileAlias.providedBy(context):
294+ raise BadMacaroonContext(context)
295+ return context.id
296+
297+ def checkVerificationContext(self, context):
298+ """See `IMacaroonIssuerBase`."""
299+ if not ILibraryFileAlias.providedBy(context):
300+ raise BadMacaroonContext(context)
301+ return context
302+
303+ def verifyPrimaryCaveat(self, caveat_value, context):
304+ """See `MacaroonIssuerBase`."""
305+ return caveat_value == str(context.id)
306
307
308 class VerifyMacaroonTests(TestCase):
309
310=== modified file 'lib/lp/services/macaroons/interfaces.py'
311--- lib/lp/services/macaroons/interfaces.py 2016-10-07 15:50:10 +0000
312+++ lib/lp/services/macaroons/interfaces.py 2019-04-24 16:19:42 +0000
313@@ -1,4 +1,4 @@
314-# Copyright 2016 Canonical Ltd. This software is licensed under the
315+# Copyright 2016-2019 Canonical Ltd. This software is licensed under the
316 # GNU Affero General Public License version 3 (see the file LICENSE).
317
318 """Interface to a policy for issuing and verifying macaroons."""
319@@ -7,28 +7,36 @@
320
321 __metaclass__ = type
322 __all__ = [
323+ 'BadMacaroonContext',
324 'IMacaroonIssuer',
325 ]
326
327 from zope.interface import Interface
328
329
330+class BadMacaroonContext(Exception):
331+ """The requested context is unsuitable."""
332+
333+ def __init__(self, context, message=None):
334+ if message is None:
335+ message = "Cannot handle context %r." % context
336+ super(BadMacaroonContext, self).__init__(message)
337+ self.context = context
338+
339+
340 class IMacaroonIssuerPublic(Interface):
341 """Public interface to a policy for verifying macaroons."""
342
343- def checkMacaroonIssuer(macaroon):
344- """Check that `macaroon` was issued by this issuer.
345-
346- This does not verify that the macaroon is valid for a given context,
347- only that it could be valid for some context. Use this in the
348- authentication part of an authentication/authorisation API.
349- """
350-
351- def verifyMacaroon(macaroon, context):
352+ def verifyMacaroon(macaroon, context, require_context=True):
353 """Verify that `macaroon` is valid for `context`.
354
355 :param macaroon: A `Macaroon`.
356 :param context: The context to check.
357+ :param require_context: If True (the default), fail verification if
358+ the context is None. If False and the context is None, only
359+ verify that the macaroon could be valid for some context. Use
360+ this in the authentication part of an
361+ authentication/authorisation API.
362 :return: True if `macaroon` is valid for `context`, otherwise False.
363 """
364
365@@ -41,5 +49,6 @@
366
367 :param context: The context that the returned macaroon should relate
368 to.
369+ :raises ValueError: if the context is unsuitable.
370 :return: A macaroon.
371 """
372
373=== added file 'lib/lp/services/macaroons/model.py'
374--- lib/lp/services/macaroons/model.py 1970-01-01 00:00:00 +0000
375+++ lib/lp/services/macaroons/model.py 2019-04-24 16:19:42 +0000
376@@ -0,0 +1,132 @@
377+# Copyright 2016-2019 Canonical Ltd. This software is licensed under the
378+# GNU Affero General Public License version 3 (see the file LICENSE).
379+
380+"""Policies for issuing and verifying macaroons."""
381+
382+from __future__ import absolute_import, print_function, unicode_literals
383+
384+__metaclass__ = type
385+__all__ = [
386+ "MacaroonIssuerBase",
387+ ]
388+
389+from pymacaroons import (
390+ Macaroon,
391+ Verifier,
392+ )
393+from pymacaroons.exceptions import MacaroonVerificationFailedException
394+
395+from lp.services.config import config
396+from lp.services.macaroons.interfaces import BadMacaroonContext
397+
398+
399+class MacaroonIssuerBase:
400+ """See `IMacaroonIssuer`."""
401+
402+ @property
403+ def identifier(self):
404+ """An identifying name for this issuer."""
405+ raise NotImplementedError
406+
407+ @property
408+ def _primary_caveat_name(self):
409+ """The name of the primary context caveat issued by this issuer."""
410+ return "lp.%s" % self.identifier
411+
412+ @property
413+ def _root_secret(self):
414+ secret = config.launchpad.internal_macaroon_secret_key
415+ if not secret:
416+ raise RuntimeError(
417+ "launchpad.internal_macaroon_secret_key not configured.")
418+ return secret
419+
420+ def checkIssuingContext(self, context):
421+ """Check that the issuing context is suitable.
422+
423+ Concrete implementations may implement this method to check that the
424+ context of a macaroon issuance is suitable. The returned context is
425+ used to create the primary caveat, and may be the same context that
426+ was passed in or an adapted one.
427+
428+ :param context: The context to check.
429+ :raises BadMacaroonContext: if the context is unsuitable.
430+ :return: The context to use to create the primary caveat.
431+ """
432+ return context
433+
434+ def issueMacaroon(self, context):
435+ """See `IMacaroonIssuer`.
436+
437+ Concrete implementations should normally wrap this with some
438+ additional checks of and/or changes to the context.
439+ """
440+ context = self.checkIssuingContext(context)
441+ macaroon = Macaroon(
442+ location=config.vhost.mainsite.hostname,
443+ identifier=self.identifier, key=self._root_secret)
444+ macaroon.add_first_party_caveat(
445+ "%s %s" % (self._primary_caveat_name, context))
446+ return macaroon
447+
448+ def checkVerificationContext(self, context):
449+ """Check that the verification context is suitable.
450+
451+ Concrete implementations may implement this method to check that the
452+ context of a macaroon verification is suitable. The returned
453+ context is passed to individual caveat checkers, and may be the same
454+ context that was passed in or an adapted one.
455+
456+ :param context: The context to check.
457+ :raises BadMacaroonContext: if the context is unsuitable.
458+ :return: The context to pass to individual caveat checkers.
459+ """
460+ return context
461+
462+ def verifyPrimaryCaveat(self, caveat_value, context):
463+ """Verify the primary context caveat on one of this issuer's macaroons.
464+
465+ :param caveat_value: The text of the caveat, with this issuer's
466+ prefix removed.
467+ :param context: The context to check.
468+ :return: True if this caveat is allowed, otherwise False.
469+ """
470+ raise NotImplementedError
471+
472+ def verifyMacaroon(self, macaroon, context, require_context=True):
473+ """See `IMacaroonIssuer`."""
474+ if macaroon.location != config.vhost.mainsite.hostname:
475+ return False
476+ if require_context and context is None:
477+ return False
478+ if context is not None:
479+ try:
480+ context = self.checkVerificationContext(context)
481+ except BadMacaroonContext:
482+ return False
483+
484+ def verify(caveat):
485+ try:
486+ caveat_name, caveat_value = caveat.split(" ", 1)
487+ except ValueError:
488+ return False
489+ if caveat_name == self._primary_caveat_name:
490+ checker = self.verifyPrimaryCaveat
491+ else:
492+ # XXX cjwatson 2019-04-09: For now we just fail closed if
493+ # there are any other caveats, which is good enough for
494+ # internal use.
495+ return False
496+ return checker(caveat_value, context)
497+
498+ try:
499+ verifier = Verifier()
500+ verifier.satisfy_general(verify)
501+ return verifier.verify(macaroon, self._root_secret)
502+ # XXX cjwatson 2019-04-24: This can currently raise a number of
503+ # other exceptions in the presence of non-well-formed input data,
504+ # but most of them are too broad to reasonably catch so we let them
505+ # turn into OOPSes for now. Revisit this once
506+ # https://github.com/ecordell/pymacaroons/issues/51 is fixed.
507+ except MacaroonVerificationFailedException:
508+ return False
509
510=== modified file 'lib/lp/snappy/configure.zcml'
511--- lib/lp/snappy/configure.zcml 2019-02-11 13:23:34 +0000
512+++ lib/lp/snappy/configure.zcml 2019-04-24 16:19:42 +0000
513@@ -83,6 +83,14 @@
514 <allow interface="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource" />
515 </securedutility>
516
517+ <!-- SnapBuildMacaroonIssuer -->
518+ <securedutility
519+ class="lp.snappy.model.snapbuild.SnapBuildMacaroonIssuer"
520+ provides="lp.services.macaroons.interfaces.IMacaroonIssuer"
521+ name="snap-build">
522+ <allow interface="lp.services.macaroons.interfaces.IMacaroonIssuerPublic"/>
523+ </securedutility>
524+
525 <!-- SnapBuildBehaviour -->
526 <adapter
527 for="lp.snappy.interfaces.snapbuild.ISnapBuild"
528
529=== modified file 'lib/lp/snappy/model/snapbuild.py'
530--- lib/lp/snappy/model/snapbuild.py 2018-12-18 18:14:37 +0000
531+++ lib/lp/snappy/model/snapbuild.py 2019-04-24 16:19:42 +0000
532@@ -1,4 +1,4 @@
533-# Copyright 2015-2018 Canonical Ltd. This software is licensed under the
534+# Copyright 2015-2019 Canonical Ltd. This software is licensed under the
535 # GNU Affero General Public License version 3 (see the file LICENSE).
536
537 __metaclass__ = type
538@@ -35,6 +35,7 @@
539 from zope.component.interfaces import ObjectEvent
540 from zope.event import notify
541 from zope.interface import implementer
542+from zope.security.proxy import removeSecurityProxy
543
544 from lp.app.errors import NotFoundError
545 from lp.buildmaster.enums import (
546@@ -45,6 +46,7 @@
547 from lp.buildmaster.interfaces.buildfarmjob import IBuildFarmJobSource
548 from lp.buildmaster.model.buildfarmjob import SpecificBuildFarmJobSourceMixin
549 from lp.buildmaster.model.packagebuild import PackageBuildMixin
550+from lp.code.interfaces.gitrepository import IGitRepository
551 from lp.registry.interfaces.pocket import PackagePublishingPocket
552 from lp.registry.model.distribution import Distribution
553 from lp.registry.model.distroseries import DistroSeries
554@@ -65,6 +67,11 @@
555 LibraryFileAlias,
556 LibraryFileContent,
557 )
558+from lp.services.macaroons.interfaces import (
559+ BadMacaroonContext,
560+ IMacaroonIssuer,
561+ )
562+from lp.services.macaroons.model import MacaroonIssuerBase
563 from lp.services.propertycache import (
564 cachedproperty,
565 get_property_cache,
566@@ -584,3 +591,53 @@
567 SnapBuild, SnapBuild.build_farm_job_id.is_in(
568 bfj.id for bfj in build_farm_jobs))
569 return DecoratedResultSet(rows, pre_iter_hook=self.preloadBuildsData)
570+
571+
572+@implementer(IMacaroonIssuer)
573+class SnapBuildMacaroonIssuer(MacaroonIssuerBase):
574+
575+ identifier = "snap-build"
576+
577+ def checkIssuingContext(self, context):
578+ """See `MacaroonIssuerBase`.
579+
580+ For issuing, the context is an `ISnapBuild` or its ID.
581+ """
582+ if ISnapBuild.providedBy(context):
583+ pass
584+ elif isinstance(context, int):
585+ context = getUtility(ISnapBuildSet).getByID(context)
586+ else:
587+ raise BadMacaroonContext(context)
588+ if not removeSecurityProxy(context).is_private:
589+ raise BadMacaroonContext(
590+ context, "Refusing to issue macaroon for public build.")
591+ return removeSecurityProxy(context).id
592+
593+ def checkVerificationContext(self, context):
594+ """See `MacaroonIssuerBase`."""
595+ if not IGitRepository.providedBy(context):
596+ raise BadMacaroonContext(context)
597+ return context
598+
599+ def verifyPrimaryCaveat(self, caveat_value, context):
600+ """See `MacaroonIssuerBase`.
601+
602+ For verification, the context is an `IGitRepository`. We check that
603+ the repository is needed to build the `ISnapBuild` that is the
604+ context of the macaroon, and that the context build is currently
605+ building.
606+ """
607+ # Circular import.
608+ from lp.snappy.model.snap import Snap
609+
610+ try:
611+ build_id = int(caveat_value)
612+ except ValueError:
613+ return False
614+ return not IStore(SnapBuild).find(
615+ SnapBuild,
616+ SnapBuild.id == build_id,
617+ SnapBuild.snap_id == Snap.id,
618+ Snap.git_repository == context,
619+ SnapBuild.status == BuildStatus.BUILDING).is_empty()
620
621=== modified file 'lib/lp/snappy/tests/test_snapbuild.py'
622--- lib/lp/snappy/tests/test_snapbuild.py 2018-12-18 18:23:52 +0000
623+++ lib/lp/snappy/tests/test_snapbuild.py 2019-04-24 16:19:42 +0000
624@@ -1,4 +1,4 @@
625-# Copyright 2015-2018 Canonical Ltd. This software is licensed under the
626+# Copyright 2015-2019 Canonical Ltd. This software is licensed under the
627 # GNU Affero General Public License version 3 (see the file LICENSE).
628
629 """Test snap package build features."""
630@@ -22,11 +22,13 @@
631 Equals,
632 Is,
633 MatchesDict,
634+ MatchesListwise,
635 MatchesStructure,
636 )
637 from zope.component import getUtility
638 from zope.security.proxy import removeSecurityProxy
639
640+from lp.app.enums import InformationType
641 from lp.app.errors import NotFoundError
642 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
643 from lp.buildmaster.enums import BuildStatus
644@@ -38,6 +40,10 @@
645 from lp.services.features.testing import FeatureFixture
646 from lp.services.job.interfaces.job import JobStatus
647 from lp.services.librarian.browser import ProxiedLibraryFileAlias
648+from lp.services.macaroons.interfaces import (
649+ BadMacaroonContext,
650+ IMacaroonIssuer,
651+ )
652 from lp.services.propertycache import clear_property_cache
653 from lp.services.webapp.interfaces import OAuthPermission
654 from lp.services.webapp.publisher import canonical_url
655@@ -774,3 +780,117 @@
656 browser = self.getNonRedirectingBrowser(user=self.person)
657 for file_url in file_urls:
658 self.assertCanOpenRedirectedUrl(browser, file_url)
659+
660+
661+class TestSnapBuildMacaroonIssuer(TestCaseWithFactory):
662+ """Test SnapBuild macaroon issuing and verification."""
663+
664+ layer = LaunchpadZopelessLayer
665+
666+ def setUp(self):
667+ super(TestSnapBuildMacaroonIssuer, self).setUp()
668+ self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
669+ self.pushConfig(
670+ "launchpad", internal_macaroon_secret_key="some-secret")
671+
672+ def test_issueMacaroon_refuses_public_snap(self):
673+ build = self.factory.makeSnapBuild()
674+ issuer = getUtility(IMacaroonIssuer, "snap-build")
675+ self.assertRaises(
676+ BadMacaroonContext, removeSecurityProxy(issuer).issueMacaroon,
677+ build)
678+
679+ def test_issueMacaroon_good(self):
680+ build = self.factory.makeSnapBuild(
681+ snap=self.factory.makeSnap(private=True))
682+ issuer = getUtility(IMacaroonIssuer, "snap-build")
683+ macaroon = removeSecurityProxy(issuer).issueMacaroon(build)
684+ self.assertThat(macaroon, MatchesStructure(
685+ location=Equals("launchpad.dev"),
686+ identifier=Equals("snap-build"),
687+ caveats=MatchesListwise([
688+ MatchesStructure.byEquality(
689+ caveat_id="lp.snap-build %s" % build.id),
690+ ])))
691+
692+ def test_verifyMacaroon_good(self):
693+ [ref] = self.factory.makeGitRefs(
694+ information_type=InformationType.USERDATA)
695+ build = self.factory.makeSnapBuild(
696+ snap=self.factory.makeSnap(git_ref=ref, private=True))
697+ build.updateStatus(BuildStatus.BUILDING)
698+ issuer = removeSecurityProxy(
699+ getUtility(IMacaroonIssuer, "snap-build"))
700+ macaroon = issuer.issueMacaroon(build)
701+ self.assertTrue(issuer.verifyMacaroon(macaroon, ref.repository))
702+
703+ def test_verifyMacaroon_wrong_location(self):
704+ [ref] = self.factory.makeGitRefs(
705+ information_type=InformationType.USERDATA)
706+ build = self.factory.makeSnapBuild(
707+ snap=self.factory.makeSnap(git_ref=ref, private=True))
708+ build.updateStatus(BuildStatus.BUILDING)
709+ issuer = removeSecurityProxy(
710+ getUtility(IMacaroonIssuer, "snap-build"))
711+ macaroon = Macaroon(
712+ location="another-location", key=issuer._root_secret)
713+ self.assertFalse(issuer.verifyMacaroon(macaroon, ref.repository))
714+
715+ def test_verifyMacaroon_wrong_key(self):
716+ [ref] = self.factory.makeGitRefs(
717+ information_type=InformationType.USERDATA)
718+ build = self.factory.makeSnapBuild(
719+ snap=self.factory.makeSnap(git_ref=ref, private=True))
720+ build.updateStatus(BuildStatus.BUILDING)
721+ issuer = removeSecurityProxy(
722+ getUtility(IMacaroonIssuer, "snap-build"))
723+ macaroon = Macaroon(
724+ location=config.vhost.mainsite.hostname, key="another-secret")
725+ self.assertFalse(issuer.verifyMacaroon(macaroon, ref.repository))
726+
727+ def test_verifyMacaroon_refuses_branch(self):
728+ branch = self.factory.makeAnyBranch(
729+ information_type=InformationType.USERDATA)
730+ build = self.factory.makeSnapBuild(
731+ snap=self.factory.makeSnap(branch=branch, private=True))
732+ build.updateStatus(BuildStatus.BUILDING)
733+ issuer = removeSecurityProxy(
734+ getUtility(IMacaroonIssuer, "snap-build"))
735+ macaroon = issuer.issueMacaroon(build)
736+ self.assertFalse(issuer.verifyMacaroon(macaroon, branch))
737+
738+ def test_verifyMacaroon_not_building(self):
739+ [ref] = self.factory.makeGitRefs(
740+ information_type=InformationType.USERDATA)
741+ build = self.factory.makeSnapBuild(
742+ snap=self.factory.makeSnap(git_ref=ref, private=True))
743+ issuer = removeSecurityProxy(
744+ getUtility(IMacaroonIssuer, "snap-build"))
745+ macaroon = issuer.issueMacaroon(build)
746+ self.assertFalse(issuer.verifyMacaroon(macaroon, ref.repository))
747+
748+ def test_verifyMacaroon_wrong_build(self):
749+ [ref] = self.factory.makeGitRefs(
750+ information_type=InformationType.USERDATA)
751+ build = self.factory.makeSnapBuild(
752+ snap=self.factory.makeSnap(git_ref=ref, private=True))
753+ build.updateStatus(BuildStatus.BUILDING)
754+ other_build = self.factory.makeSnapBuild(
755+ snap=self.factory.makeSnap(private=True))
756+ other_build.updateStatus(BuildStatus.BUILDING)
757+ issuer = removeSecurityProxy(
758+ getUtility(IMacaroonIssuer, "snap-build"))
759+ macaroon = issuer.issueMacaroon(other_build)
760+ self.assertFalse(issuer.verifyMacaroon(macaroon, ref.repository))
761+
762+ def test_verifyMacaroon_wrong_repository(self):
763+ [ref] = self.factory.makeGitRefs(
764+ information_type=InformationType.USERDATA)
765+ build = self.factory.makeSnapBuild(
766+ snap=self.factory.makeSnap(git_ref=ref, private=True))
767+ other_repository = self.factory.makeGitRepository()
768+ build.updateStatus(BuildStatus.BUILDING)
769+ issuer = removeSecurityProxy(
770+ getUtility(IMacaroonIssuer, "snap-build"))
771+ macaroon = issuer.issueMacaroon(build)
772+ self.assertFalse(issuer.verifyMacaroon(macaroon, other_repository))
773
774=== modified file 'lib/lp/soyuz/model/binarypackagebuild.py'
775--- lib/lp/soyuz/model/binarypackagebuild.py 2019-04-23 14:00:43 +0000
776+++ lib/lp/soyuz/model/binarypackagebuild.py 2019-04-24 16:19:42 +0000
777@@ -21,10 +21,6 @@
778
779 import apt_pkg
780 from debian.deb822 import PkgRelation
781-from pymacaroons import (
782- Macaroon,
783- Verifier,
784- )
785 import pytz
786 from sqlobject import SQLObjectNotFound
787 from storm.expr import (
788@@ -86,7 +82,11 @@
789 LibraryFileAlias,
790 LibraryFileContent,
791 )
792-from lp.services.macaroons.interfaces import IMacaroonIssuer
793+from lp.services.macaroons.interfaces import (
794+ BadMacaroonContext,
795+ IMacaroonIssuer,
796+ )
797+from lp.services.macaroons.model import MacaroonIssuerBase
798 from lp.soyuz.adapters.buildarch import determine_architectures_to_build
799 from lp.soyuz.enums import (
800 ArchivePurpose,
801@@ -1374,52 +1374,39 @@
802
803
804 @implementer(IMacaroonIssuer)
805-class BinaryPackageBuildMacaroonIssuer:
806+class BinaryPackageBuildMacaroonIssuer(MacaroonIssuerBase):
807+
808+ identifier = "binary-package-build"
809
810 @property
811- def _root_secret(self):
812- secret = config.launchpad.internal_macaroon_secret_key
813- if not secret:
814- raise RuntimeError(
815- "launchpad.internal_macaroon_secret_key not configured.")
816- return secret
817-
818- def issueMacaroon(self, context):
819- """See `IMacaroonIssuer`.
820-
821- For issuing, the context is an `IBinaryPackageBuild`.
822- """
823- if not removeSecurityProxy(context).archive.private:
824- raise ValueError("Refusing to issue macaroon for public build.")
825- macaroon = Macaroon(
826- location=config.vhost.mainsite.hostname,
827- identifier="binary-package-build", key=self._root_secret)
828+ def _primary_caveat_name(self):
829+ """See `MacaroonIssuerBase`."""
830 # The "lp.principal" prefix indicates that this caveat constrains
831 # the macaroon to access only resources that should be accessible
832 # when acting on behalf of the named build, rather than to access
833 # the named build directly.
834- macaroon.add_first_party_caveat(
835- "lp.principal.binary-package-build %s" %
836- removeSecurityProxy(context).id)
837- return macaroon
838-
839- def checkMacaroonIssuer(self, macaroon):
840- """See `IMacaroonIssuer`."""
841- if macaroon.location != config.vhost.mainsite.hostname:
842- return False
843- try:
844- verifier = Verifier()
845- verifier.satisfy_general(
846- lambda caveat: caveat.startswith(
847- "lp.principal.binary-package-build "))
848- return verifier.verify(macaroon, self._root_secret)
849- except Exception:
850- return False
851-
852- def verifyMacaroon(self, macaroon, context):
853- """See `IMacaroonIssuer`.
854-
855- For verification, the context is a `LibraryFileAlias`. We check
856+ return "lp.principal.binary-package-build"
857+
858+ def checkIssuingContext(self, context):
859+ """See `MacaroonIssuerBase`.
860+
861+ For issuing, the context is an `IBinaryPackageBuild`.
862+ """
863+ if not removeSecurityProxy(context).archive.private:
864+ raise BadMacaroonContext(
865+ context, "Refusing to issue macaroon for public build.")
866+ return removeSecurityProxy(context).id
867+
868+ def checkVerificationContext(self, context):
869+ """See `MacaroonIssuerBase`."""
870+ if not ILibraryFileAlias.providedBy(context):
871+ raise BadMacaroonContext(context)
872+ return context
873+
874+ def verifyPrimaryCaveat(self, caveat_value, context):
875+ """See `MacaroonIssuerBase`.
876+
877+ For verification, the context is an `ILibraryFileAlias`. We check
878 that the file is one of those required to build the
879 `IBinaryPackageBuild` that is the context of the macaroon, and that
880 the context build is currently building.
881@@ -1427,32 +1414,16 @@
882 # Circular import.
883 from lp.soyuz.model.sourcepackagerelease import SourcePackageRelease
884
885- if not ILibraryFileAlias.providedBy(context):
886- return False
887- if not self.checkMacaroonIssuer(macaroon):
888- return False
889-
890- def verify_build(caveat):
891- prefix = "lp.principal.binary-package-build "
892- if not caveat.startswith(prefix):
893- return False
894- try:
895- build_id = int(caveat[len(prefix):])
896- except ValueError:
897- return False
898- return not IStore(BinaryPackageBuild).find(
899- BinaryPackageBuild,
900- BinaryPackageBuild.id == build_id,
901- BinaryPackageBuild.source_package_release_id ==
902- SourcePackageRelease.id,
903- SourcePackageReleaseFile.sourcepackagereleaseID ==
904- SourcePackageRelease.id,
905- SourcePackageReleaseFile.libraryfile == context,
906- BinaryPackageBuild.status == BuildStatus.BUILDING).is_empty()
907-
908 try:
909- verifier = Verifier()
910- verifier.satisfy_general(verify_build)
911- return verifier.verify(macaroon, self._root_secret)
912- except Exception:
913+ build_id = int(caveat_value)
914+ except ValueError:
915 return False
916+ return not IStore(BinaryPackageBuild).find(
917+ BinaryPackageBuild,
918+ BinaryPackageBuild.id == build_id,
919+ BinaryPackageBuild.source_package_release_id ==
920+ SourcePackageRelease.id,
921+ SourcePackageReleaseFile.sourcepackagereleaseID ==
922+ SourcePackageRelease.id,
923+ SourcePackageReleaseFile.libraryfile == context,
924+ BinaryPackageBuild.status == BuildStatus.BUILDING).is_empty()
925
926=== modified file 'lib/lp/soyuz/tests/test_binarypackagebuild.py'
927--- lib/lp/soyuz/tests/test_binarypackagebuild.py 2019-04-23 14:00:43 +0000
928+++ lib/lp/soyuz/tests/test_binarypackagebuild.py 2019-04-24 16:19:42 +0000
929@@ -29,7 +29,10 @@
930 from lp.registry.interfaces.sourcepackage import SourcePackageUrgency
931 from lp.services.config import config
932 from lp.services.log.logger import DevNullLogger
933-from lp.services.macaroons.interfaces import IMacaroonIssuer
934+from lp.services.macaroons.interfaces import (
935+ BadMacaroonContext,
936+ IMacaroonIssuer,
937+ )
938 from lp.services.webapp.interaction import ANONYMOUS
939 from lp.services.webapp.interfaces import OAuthPermission
940 from lp.soyuz.enums import (
941@@ -920,7 +923,8 @@
942 build = self.factory.makeBinaryPackageBuild()
943 issuer = getUtility(IMacaroonIssuer, "binary-package-build")
944 self.assertRaises(
945- ValueError, removeSecurityProxy(issuer).issueMacaroon, build)
946+ BadMacaroonContext, removeSecurityProxy(issuer).issueMacaroon,
947+ build)
948
949 def test_issueMacaroon_good(self):
950 build = self.factory.makeBinaryPackageBuild(
951@@ -934,26 +938,6 @@
952 caveat_id="lp.principal.binary-package-build %s" % build.id),
953 ]))
954
955- def test_checkMacaroonIssuer_good(self):
956- build = self.factory.makeBinaryPackageBuild(
957- archive=self.factory.makeArchive(private=True))
958- issuer = getUtility(IMacaroonIssuer, "binary-package-build")
959- macaroon = removeSecurityProxy(issuer).issueMacaroon(build)
960- self.assertTrue(issuer.checkMacaroonIssuer(macaroon))
961-
962- def test_checkMacaroonIssuer_wrong_location(self):
963- issuer = getUtility(IMacaroonIssuer, "binary-package-build")
964- macaroon = Macaroon(
965- location="another-location",
966- key=removeSecurityProxy(issuer)._root_secret)
967- self.assertFalse(issuer.checkMacaroonIssuer(macaroon))
968-
969- def test_checkMacaroonIssuer_wrong_key(self):
970- issuer = getUtility(IMacaroonIssuer, "binary-package-build")
971- macaroon = Macaroon(
972- location=config.vhost.mainsite.hostname, key="another-secret")
973- self.assertFalse(issuer.checkMacaroonIssuer(macaroon))
974-
975 def test_verifyMacaroon_good(self):
976 build = self.factory.makeBinaryPackageBuild(
977 archive=self.factory.makeArchive(private=True))