Merge lp:~cjwatson/launchpad/snap-build-macaroon into lp:launchpad
- snap-build-macaroon
- Merge into devel
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 | ||||
Related bugs: |
|
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)) |