Merge lp:~cjwatson/launchpad/snap-authorize-view into lp:launchpad
- snap-authorize-view
- Merge into devel
Status: | Merged | ||||
---|---|---|---|---|---|
Merged at revision: | 18058 | ||||
Proposed branch: | lp:~cjwatson/launchpad/snap-authorize-view | ||||
Merge into: | lp:launchpad | ||||
Prerequisite: | lp:~cjwatson/launchpad/login-discharge-macaroon | ||||
Diff against target: |
767 lines (+396/-55) 11 files modified
lib/lp/registry/model/person.py (+2/-7) lib/lp/services/openid/adapters/openid.py (+12/-2) lib/lp/services/webapp/login.py (+15/-32) lib/lp/services/webapp/templates/login-discharge-macaroon.pt (+36/-0) lib/lp/services/webapp/tests/test_login.py (+29/-14) lib/lp/snappy/browser/configure.zcml (+6/-0) lib/lp/snappy/browser/snap.py (+116/-0) lib/lp/snappy/browser/tests/test_snap.py (+153/-0) lib/lp/snappy/templates/snap-authorize.pt (+24/-0) setup.py (+1/-0) versions.cfg (+2/-0) |
||||
To merge this branch: | bzr merge lp:~cjwatson/launchpad/snap-authorize-view | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
William Grant | code | Approve | |
Review via email: mp+294358@code.launchpad.net |
Commit message
Add a Snap:+authorize view allowing a user to (re)authorise snap package uploads to the store.
Description of the change
Now that +login supports acquiring discharge macaroons (see the prerequisite branch), we can add a view that fetches a root macaroon from SCA, sends the user off to SSO to get a discharge for it via OpenID, and stores the result when they come back. In subsequent branches, we'll redirect to this view when users make changes to store upload settings, and mail the user if their existing store secrets have expired pointing them to this view.
Since we don't want to give SSO access to the root macaroon (it happens to be fine in this instance, but is a poor precedent to set), we store it in Snap.store_secrets before the exchange is complete. This means that if you hit Snap:+authorize when you already had valid secrets then Launchpad won't be able to upload builds for you until you complete the exchange. Fortunately this is mostly "don't do that, then".
Since this introduces pymacaroons, we need https:/
William Grant (wgrant) : | # |
Preview Diff
1 | === modified file 'lib/lp/registry/model/person.py' |
2 | --- lib/lp/registry/model/person.py 2016-04-26 11:06:17 +0000 |
3 | +++ lib/lp/registry/model/person.py 2016-05-19 02:03:28 +0000 |
4 | @@ -289,6 +289,7 @@ |
5 | OAuthAccessToken, |
6 | OAuthRequestToken, |
7 | ) |
8 | +from lp.services.openid.adapters.openid import CurrentOpenIDEndPoint |
9 | from lp.services.openid.model.openididentifier import OpenIdIdentifier |
10 | from lp.services.propertycache import ( |
11 | cachedproperty, |
12 | @@ -3280,13 +3281,7 @@ |
13 | # + is reserved, so is not allowed to be reencoded in transit, so |
14 | # should never appear as its percent-encoded equivalent. |
15 | identifier_suffix = None |
16 | - roots = [config.launchpad.openid_provider_root] |
17 | - if config.launchpad.openid_alternate_provider_roots: |
18 | - roots.extend( |
19 | - [root.strip() for root in |
20 | - config.launchpad.openid_alternate_provider_roots.split(',') |
21 | - if root.strip()]) |
22 | - for root in roots: |
23 | + for root in CurrentOpenIDEndPoint.getAllRootURLs(): |
24 | base = '%s+id/' % root |
25 | if identifier.startswith(base): |
26 | identifier_suffix = identifier.replace(base, '', 1) |
27 | |
28 | === modified file 'lib/lp/services/openid/adapters/openid.py' |
29 | --- lib/lp/services/openid/adapters/openid.py 2016-05-18 00:33:18 +0000 |
30 | +++ lib/lp/services/openid/adapters/openid.py 2016-05-19 02:03:28 +0000 |
31 | @@ -24,11 +24,21 @@ |
32 | class CurrentOpenIDEndPoint: |
33 | """A utility for working with multiple OpenID End Points.""" |
34 | |
35 | - @classmethod |
36 | - def getServiceURL(cls): |
37 | + @staticmethod |
38 | + def getServiceURL(): |
39 | """The OpenID server URL (/+openid) for the current request.""" |
40 | return config.launchpad.openid_provider_root + '+openid' |
41 | |
42 | + @staticmethod |
43 | + def getAllRootURLs(): |
44 | + """All configured OpenID provider root URLs.""" |
45 | + yield config.launchpad.openid_provider_root |
46 | + alternate_roots = config.launchpad.openid_alternate_provider_roots |
47 | + if alternate_roots: |
48 | + for root in [r.strip() for r in alternate_roots.split(',')]: |
49 | + if root: |
50 | + yield root |
51 | + |
52 | |
53 | @adapter(IAccount) |
54 | @implementer(IOpenIDPersistentIdentity) |
55 | |
56 | === modified file 'lib/lp/services/webapp/login.py' |
57 | --- lib/lp/services/webapp/login.py 2016-05-11 10:45:12 +0000 |
58 | +++ lib/lp/services/webapp/login.py 2016-05-19 02:03:28 +0000 |
59 | @@ -1,4 +1,4 @@ |
60 | -# Copyright 2009-2011 Canonical Ltd. This software is licensed under the |
61 | +# Copyright 2009-2016 Canonical Ltd. This software is licensed under the |
62 | # GNU Affero General Public License version 3 (see the file LICENSE). |
63 | """Stuff to do with logging in and logging out.""" |
64 | |
65 | @@ -9,10 +9,6 @@ |
66 | timedelta, |
67 | ) |
68 | import urllib |
69 | -from urlparse import ( |
70 | - parse_qsl, |
71 | - urlunsplit, |
72 | - ) |
73 | |
74 | from openid.consumer.consumer import ( |
75 | CANCEL, |
76 | @@ -62,10 +58,7 @@ |
77 | from lp.services.openid.interfaces.openidconsumer import IOpenIDConsumerStore |
78 | from lp.services.propertycache import cachedproperty |
79 | from lp.services.timeline.requesttimeline import get_request_timeline |
80 | -from lp.services.webapp import ( |
81 | - canonical_url, |
82 | - urlsplit, |
83 | - ) |
84 | +from lp.services.webapp import canonical_url |
85 | from lp.services.webapp.error import SystemErrorView |
86 | from lp.services.webapp.interfaces import ( |
87 | CookieAuthLoggedInEvent, |
88 | @@ -226,11 +219,11 @@ |
89 | # '+login' bit). To do that we encode that URL as a query arg in the |
90 | # return_to URL passed to the OpenID Provider |
91 | starting_data = [('starting_url', self.starting_url.encode('utf-8'))] |
92 | - discharge_macaroon_field = self.request.form.get( |
93 | - 'discharge_macaroon_field', None) |
94 | - if discharge_macaroon_field is not None: |
95 | - starting_data.append( |
96 | - ('discharge_macaroon_field', discharge_macaroon_field)) |
97 | + for passthrough_name in ( |
98 | + 'discharge_macaroon_action', 'discharge_macaroon_field'): |
99 | + passthrough_field = self.request.form.get(passthrough_name, None) |
100 | + if passthrough_field is not None: |
101 | + starting_data.append((passthrough_name, passthrough_field)) |
102 | starting_url = urllib.urlencode(starting_data) |
103 | trust_root = allvhosts.configs['mainsite'].rooturl |
104 | return_to = urlappend(trust_root, '+openid-callback') |
105 | @@ -264,7 +257,8 @@ |
106 | """ |
107 | for name, value in self.request.form.items(): |
108 | if name in ('loggingout', 'reauth', |
109 | - 'macaroon_caveat_id', 'discharge_macaroon_field'): |
110 | + 'macaroon_caveat_id', 'discharge_macaroon_action', |
111 | + 'discharge_macaroon_field'): |
112 | continue |
113 | if name.startswith('openid.'): |
114 | continue |
115 | @@ -299,6 +293,9 @@ |
116 | team_email_address_template = ViewPageTemplateFile( |
117 | 'templates/login-team-email-address.pt') |
118 | |
119 | + discharge_macaroon_template = ViewPageTemplateFile( |
120 | + 'templates/login-discharge-macaroon.pt') |
121 | + |
122 | def _gather_params(self, request): |
123 | params = dict(request.form) |
124 | for key, value in request.query_string_params.iteritems(): |
125 | @@ -407,6 +404,9 @@ |
126 | with MasterDatabasePolicy(): |
127 | self.login(person) |
128 | |
129 | + if self.params.get('discharge_macaroon_field'): |
130 | + return self.discharge_macaroon_template() |
131 | + |
132 | if should_update_last_write: |
133 | # This is a GET request but we changed the database, so update |
134 | # session_data['last_write'] to make sure further requests use |
135 | @@ -448,27 +448,10 @@ |
136 | transaction.commit() |
137 | return retval |
138 | |
139 | - @staticmethod |
140 | - def _appendParam(url, key, value): |
141 | - """Append a parameter to a URL's query string.""" |
142 | - parts = urlsplit(url) |
143 | - query = parse_qsl(parts.query) |
144 | - query.append((key, value)) |
145 | - return urlunsplit( |
146 | - (parts.scheme, parts.netloc, parts.path, urllib.urlencode(query), |
147 | - parts.fragment)) |
148 | - |
149 | def _redirect(self): |
150 | target = self.params.get('starting_url') |
151 | if target is None: |
152 | target = self.request.getApplicationURL() |
153 | - discharge_macaroon_field = self.params.get('discharge_macaroon_field') |
154 | - if (discharge_macaroon_field is not None and |
155 | - self.discharge_macaroon_raw is not None): |
156 | - # XXX cjwatson 2016-04-18: Do we need to POST this instead due |
157 | - # to size? |
158 | - target = self._appendParam( |
159 | - target, discharge_macaroon_field, self.discharge_macaroon_raw) |
160 | self.request.response.redirect(target, temporary_if_possible=True) |
161 | |
162 | |
163 | |
164 | === added file 'lib/lp/services/webapp/templates/login-discharge-macaroon.pt' |
165 | --- lib/lp/services/webapp/templates/login-discharge-macaroon.pt 1970-01-01 00:00:00 +0000 |
166 | +++ lib/lp/services/webapp/templates/login-discharge-macaroon.pt 2016-05-19 02:03:28 +0000 |
167 | @@ -0,0 +1,36 @@ |
168 | +<html |
169 | + xmlns="http://www.w3.org/1999/xhtml" |
170 | + xmlns:tal="http://xml.zope.org/namespaces/tal" |
171 | + xmlns:metal="http://xml.zope.org/namespaces/metal" |
172 | + xmlns:i18n="http://xml.zope.org/namespaces/i18n" |
173 | + metal:use-macro="view/macro:page/main_only" |
174 | + i18n:domain="launchpad"> |
175 | + |
176 | + <body onload="document.forms[0].submit();"> |
177 | + <div class="top-portlet" metal:fill-slot="main"> |
178 | + <h1>OpenID transaction in progress</h1> |
179 | + |
180 | + <form tal:attributes="action view/params/starting_url" |
181 | + id="discharge-form" |
182 | + method="post" |
183 | + enctype="application/x-www-form-urlencoded" |
184 | + accept-charset="UTF-8"> |
185 | + <input type="hidden" |
186 | + tal:condition="view/params/discharge_macaroon_action" |
187 | + tal:attributes="name view/params/discharge_macaroon_action" |
188 | + value="1" /> |
189 | + <input type="hidden" |
190 | + tal:attributes="name view/params/discharge_macaroon_field; |
191 | + value view/discharge_macaroon_raw" /> |
192 | + </form> |
193 | + </div> |
194 | + |
195 | + <script> |
196 | + var elements = document.forms[0].elements; |
197 | + for (var i = 0; i < elements.length; i++) { |
198 | + elements[i].style.display = "none"; |
199 | + } |
200 | + </script> |
201 | + </body> |
202 | + |
203 | +</html> |
204 | |
205 | === modified file 'lib/lp/services/webapp/tests/test_login.py' |
206 | --- lib/lp/services/webapp/tests/test_login.py 2016-05-11 10:45:12 +0000 |
207 | +++ lib/lp/services/webapp/tests/test_login.py 2016-05-19 02:03:28 +0000 |
208 | @@ -1,4 +1,4 @@ |
209 | -# Copyright 2009-2012 Canonical Ltd. This software is licensed under the |
210 | +# Copyright 2009-2016 Canonical Ltd. This software is licensed under the |
211 | # GNU Affero General Public License version 3 (see the file LICENSE). |
212 | """Test harness for running the new-login.txt tests.""" |
213 | |
214 | @@ -33,7 +33,12 @@ |
215 | sreg, |
216 | ) |
217 | from openid.yadis.discover import DiscoveryFailure |
218 | -from testtools.matchers import Contains |
219 | +from testtools.matchers import ( |
220 | + Contains, |
221 | + ContainsDict, |
222 | + Equals, |
223 | + MatchesListwise, |
224 | + ) |
225 | from zope.component import getUtility |
226 | from zope.security.management import newInteraction |
227 | from zope.security.proxy import removeSecurityProxy |
228 | @@ -481,8 +486,8 @@ |
229 | main_content) |
230 | |
231 | def test_discharge_macaroon(self): |
232 | - # If a discharge macaroon was requested and received, it is added to |
233 | - # the starting URL as a query string parameter. |
234 | + # If a discharge macaroon was requested and received, the view |
235 | + # returns a form that submits it to the starting URL. |
236 | test_email = 'test-example@example.com' |
237 | person = self.factory.makePerson(email=test_email) |
238 | identifier = ITestOpenIDPersistentIdentity( |
239 | @@ -492,6 +497,7 @@ |
240 | full_name='Foo User', discharge_macaroon_raw='dummy discharge') |
241 | form = { |
242 | 'starting_url': 'http://launchpad.dev/after-login', |
243 | + 'discharge_macaroon_action': 'field.actions.complete', |
244 | 'discharge_macaroon_field': 'field.discharge_macaroon', |
245 | } |
246 | with SRegResponse_fromSuccessResponse_stubbed(): |
247 | @@ -500,12 +506,20 @@ |
248 | openid_response, form=form) |
249 | self.assertTrue(view.login_called) |
250 | self.assertEqual('dummy discharge', view.discharge_macaroon_raw) |
251 | - response = view.request.response |
252 | - self.assertEqual(httplib.TEMPORARY_REDIRECT, response.getStatus()) |
253 | - self.assertEqual( |
254 | - form['starting_url'] + |
255 | - '?field.discharge_macaroon=dummy+discharge', |
256 | - response.getHeader('Location')) |
257 | + discharge_form = find_tag_by_id(html, 'discharge-form') |
258 | + self.assertEqual(form['starting_url'], discharge_form['action']) |
259 | + self.assertThat( |
260 | + [dict(tag.attrs) for tag in discharge_form.findAll('input')], |
261 | + MatchesListwise([ |
262 | + ContainsDict({ |
263 | + 'name': Equals('field.actions.complete'), |
264 | + 'value': Equals('1'), |
265 | + }), |
266 | + ContainsDict({ |
267 | + 'name': Equals('field.discharge_macaroon'), |
268 | + 'value': Equals('dummy discharge'), |
269 | + }), |
270 | + ])) |
271 | |
272 | def test_discharge_macaroon_missing(self): |
273 | # If a discharge macaroon was requested but not received, the login |
274 | @@ -519,6 +533,7 @@ |
275 | full_name='Foo User') |
276 | form = { |
277 | 'starting_url': 'http://launchpad.dev/after-login', |
278 | + 'discharge_macaroon_action': 'field.actions.complete', |
279 | 'discharge_macaroon_field': 'field.discharge_macaroon', |
280 | } |
281 | with SRegResponse_fromSuccessResponse_stubbed(): |
282 | @@ -852,8 +867,8 @@ |
283 | # extension. |
284 | caveat_id = 'ask SSO' |
285 | form = { |
286 | - 'field.callback': '1', |
287 | 'macaroon_caveat_id': caveat_id, |
288 | + 'discharge_macaroon_action': 'field.actions.complete', |
289 | 'discharge_macaroon_field': 'field.discharge_macaroon', |
290 | } |
291 | request = LaunchpadTestRequest(form=form, method='POST') |
292 | @@ -871,11 +886,11 @@ |
293 | return_to_args = dict(urlparse.parse_qsl( |
294 | urlparse.urlsplit(view.openid_request.return_to).query)) |
295 | self.assertEqual( |
296 | + 'field.actions.complete', |
297 | + return_to_args['discharge_macaroon_action']) |
298 | + self.assertEqual( |
299 | 'field.discharge_macaroon', |
300 | return_to_args['discharge_macaroon_field']) |
301 | - starting_url_args = dict(urlparse.parse_qsl( |
302 | - urlparse.urlsplit(return_to_args['starting_url']).query)) |
303 | - self.assertEqual('1', starting_url_args['field.callback']) |
304 | |
305 | def test_logs_to_timeline(self): |
306 | # Beginning an OpenID association makes an HTTP request to the |
307 | |
308 | === modified file 'lib/lp/snappy/browser/configure.zcml' |
309 | --- lib/lp/snappy/browser/configure.zcml 2016-05-06 11:54:18 +0000 |
310 | +++ lib/lp/snappy/browser/configure.zcml 2016-05-19 02:03:28 +0000 |
311 | @@ -68,6 +68,12 @@ |
312 | template="../templates/snap-edit.pt" /> |
313 | <browser:page |
314 | for="lp.snappy.interfaces.snap.ISnap" |
315 | + class="lp.snappy.browser.snap.SnapAuthorizeView" |
316 | + permission="launchpad.Edit" |
317 | + name="+authorize" |
318 | + template="../templates/snap-authorize.pt" /> |
319 | + <browser:page |
320 | + for="lp.snappy.interfaces.snap.ISnap" |
321 | class="lp.snappy.browser.snap.SnapDeleteView" |
322 | permission="launchpad.Edit" |
323 | name="+delete" |
324 | |
325 | === modified file 'lib/lp/snappy/browser/snap.py' |
326 | --- lib/lp/snappy/browser/snap.py 2016-05-06 09:45:45 +0000 |
327 | +++ lib/lp/snappy/browser/snap.py 2016-05-19 02:03:28 +0000 |
328 | @@ -6,6 +6,7 @@ |
329 | __metaclass__ = type |
330 | __all__ = [ |
331 | 'SnapAddView', |
332 | + 'SnapAuthorizeView', |
333 | 'SnapContextMenu', |
334 | 'SnapDeleteView', |
335 | 'SnapEditView', |
336 | @@ -15,18 +16,24 @@ |
337 | 'SnapView', |
338 | ] |
339 | |
340 | +from urllib import urlencode |
341 | +from urlparse import urlsplit |
342 | + |
343 | from lazr.restful.fields import Reference |
344 | from lazr.restful.interface import ( |
345 | copy_field, |
346 | use_template, |
347 | ) |
348 | +from pymacaroons import Macaroon |
349 | from zope.component import getUtility |
350 | from zope.interface import Interface |
351 | from zope.schema import ( |
352 | Choice, |
353 | List, |
354 | + TextLine, |
355 | ) |
356 | |
357 | +from lp import _ |
358 | from lp.app.browser.launchpadform import ( |
359 | action, |
360 | custom_widget, |
361 | @@ -49,6 +56,7 @@ |
362 | from lp.registry.interfaces.pocket import PackagePublishingPocket |
363 | from lp.services.features import getFeatureFlag |
364 | from lp.services.helpers import english_list |
365 | +from lp.services.openid.adapters.openid import CurrentOpenIDEndPoint |
366 | from lp.services.webapp import ( |
367 | canonical_url, |
368 | ContextMenu, |
369 | @@ -59,6 +67,7 @@ |
370 | NavigationMenu, |
371 | stepthrough, |
372 | structured, |
373 | + urlappend, |
374 | ) |
375 | from lp.services.webapp.authorization import check_permission |
376 | from lp.services.webapp.breadcrumb import ( |
377 | @@ -78,6 +87,7 @@ |
378 | SnapPrivateFeatureDisabled, |
379 | ) |
380 | from lp.snappy.interfaces.snapbuild import ISnapBuildSet |
381 | +from lp.snappy.interfaces.snapstoreclient import ISnapStoreClient |
382 | from lp.soyuz.browser.archive import EnableProcessorsMixin |
383 | from lp.soyuz.browser.build import get_build_by_id_str |
384 | from lp.soyuz.interfaces.archive import IArchive |
385 | @@ -503,6 +513,112 @@ |
386 | data['processors'].append(processor) |
387 | |
388 | |
389 | +class SnapAuthorizationException(Exception): |
390 | + pass |
391 | + |
392 | + |
393 | +class SnapAuthorizeView(LaunchpadEditFormView): |
394 | + """View for authorizing snap package uploads to the store.""" |
395 | + |
396 | + @property |
397 | + def label(self): |
398 | + return 'Authorize store uploads of %s' % self.context.name |
399 | + |
400 | + page_title = 'Authorize store uploads' |
401 | + |
402 | + class schema(Interface): |
403 | + """Schema for authorizing snap package uploads to the store.""" |
404 | + |
405 | + discharge_macaroon = TextLine( |
406 | + title=u'Serialized discharge macaroon', required=True) |
407 | + |
408 | + render_context = False |
409 | + |
410 | + focusedElementScript = None |
411 | + |
412 | + @property |
413 | + def cancel_url(self): |
414 | + return canonical_url(self.context) |
415 | + |
416 | + @staticmethod |
417 | + def extractSSOCaveat(macaroon): |
418 | + locations = [ |
419 | + urlsplit(root).netloc |
420 | + for root in CurrentOpenIDEndPoint.getAllRootURLs()] |
421 | + sso_caveats = [ |
422 | + c for c in macaroon.third_party_caveats() |
423 | + if c.location in locations] |
424 | + # We must have exactly one SSO caveat; more than one should never be |
425 | + # required and could be an attempt to substitute weaker caveats. We |
426 | + # might as well OOPS here, even though the cause of this is probably |
427 | + # in some other service, since the user can't do anything about it |
428 | + # and it should show up in our OOPS reports. |
429 | + if not sso_caveats: |
430 | + raise SnapAuthorizationException("Macaroon has no SSO caveats") |
431 | + elif len(sso_caveats) > 1: |
432 | + raise SnapAuthorizationException( |
433 | + "Macaroon has multiple SSO caveats") |
434 | + return sso_caveats[0] |
435 | + |
436 | + @classmethod |
437 | + def requestAuthorization(cls, snap, request): |
438 | + """Begin the process of authorizing uploads of a snap package.""" |
439 | + if snap.store_series is None: |
440 | + request.response.addInfoNotification( |
441 | + _(u'Cannot authorize uploads of a snap package with no ' |
442 | + u'store series.')) |
443 | + request.response.redirect(canonical_url(snap)) |
444 | + return |
445 | + if snap.store_name is None: |
446 | + request.response.addInfoNotification( |
447 | + _(u'Cannot authorize uploads of a snap package with no ' |
448 | + u'store name.')) |
449 | + request.response.redirect(canonical_url(snap)) |
450 | + return |
451 | + snap_store_client = getUtility(ISnapStoreClient) |
452 | + root_macaroon_raw = snap_store_client.requestPackageUploadPermission( |
453 | + snap.store_series, snap.store_name) |
454 | + sso_caveat = cls.extractSSOCaveat( |
455 | + Macaroon.deserialize(root_macaroon_raw)) |
456 | + snap.store_secrets = {'root': root_macaroon_raw} |
457 | + base_url = canonical_url(snap, view_name='+authorize') |
458 | + login_url = urlappend(base_url, '+login') |
459 | + login_url += '?%s' % urlencode([ |
460 | + ('macaroon_caveat_id', sso_caveat.caveat_id), |
461 | + ('discharge_macaroon_action', 'field.actions.complete'), |
462 | + ('discharge_macaroon_field', 'field.discharge_macaroon'), |
463 | + ]) |
464 | + return login_url |
465 | + |
466 | + @action('Begin authorization', name='begin') |
467 | + def begin_action(self, action, data): |
468 | + login_url = self.requestAuthorization(self.context, self.request) |
469 | + if login_url is not None: |
470 | + self.request.response.redirect(login_url) |
471 | + |
472 | + @action('Complete authorization', name='complete') |
473 | + def complete_action(self, action, data): |
474 | + if not data.get('discharge_macaroon'): |
475 | + self.addError(structured( |
476 | + _(u'Uploads of %(snap)s to the store were not authorized.'), |
477 | + snap=self.context.name)) |
478 | + return |
479 | + # We have to set a whole new dict here to avoid problems with |
480 | + # security proxies. |
481 | + new_store_secrets = dict(self.context.store_secrets) |
482 | + new_store_secrets['discharge'] = data['discharge_macaroon'] |
483 | + self.context.store_secrets = new_store_secrets |
484 | + self.request.response.addInfoNotification(structured( |
485 | + _(u'Uploads of %(snap)s to the store are now authorized.'), |
486 | + snap=self.context.name)) |
487 | + self.request.response.redirect(canonical_url(self.context)) |
488 | + |
489 | + @property |
490 | + def adapters(self): |
491 | + """See `LaunchpadFormView`.""" |
492 | + return {self.schema: self.context} |
493 | + |
494 | + |
495 | class SnapDeleteView(BaseSnapEditView): |
496 | """View for deleting snap packages.""" |
497 | |
498 | |
499 | === modified file 'lib/lp/snappy/browser/tests/test_snap.py' |
500 | --- lib/lp/snappy/browser/tests/test_snap.py 2016-05-06 09:45:45 +0000 |
501 | +++ lib/lp/snappy/browser/tests/test_snap.py 2016-05-19 02:03:28 +0000 |
502 | @@ -9,17 +9,26 @@ |
503 | datetime, |
504 | timedelta, |
505 | ) |
506 | +import json |
507 | import re |
508 | from textwrap import dedent |
509 | +from urllib2 import HTTPError |
510 | +from urlparse import urlsplit |
511 | |
512 | from fixtures import FakeLogger |
513 | +from httmock import ( |
514 | + all_requests, |
515 | + HTTMock, |
516 | + ) |
517 | from mechanize import LinkNotFoundError |
518 | +from pymacaroons import Macaroon |
519 | import pytz |
520 | import soupmatchers |
521 | from testtools.matchers import ( |
522 | MatchesSetwise, |
523 | MatchesStructure, |
524 | ) |
525 | +import transaction |
526 | from zope.component import getUtility |
527 | from zope.publisher.interfaces import NotFound |
528 | from zope.security.interfaces import Unauthorized |
529 | @@ -31,12 +40,14 @@ |
530 | from lp.registry.enums import PersonVisibility |
531 | from lp.registry.interfaces.pocket import PackagePublishingPocket |
532 | from lp.registry.interfaces.series import SeriesStatus |
533 | +from lp.services.config import config |
534 | from lp.services.database.constants import UTC_NOW |
535 | from lp.services.features.testing import FeatureFixture |
536 | from lp.services.webapp import canonical_url |
537 | from lp.services.webapp.servers import LaunchpadTestRequest |
538 | from lp.snappy.browser.snap import ( |
539 | SnapAdminView, |
540 | + SnapAuthorizeView, |
541 | SnapEditView, |
542 | SnapView, |
543 | ) |
544 | @@ -48,6 +59,7 @@ |
545 | SnapPrivateFeatureDisabled, |
546 | ) |
547 | from lp.testing import ( |
548 | + admin_logged_in, |
549 | BrowserTestCase, |
550 | feature_flags, |
551 | login, |
552 | @@ -70,6 +82,7 @@ |
553 | find_main_content, |
554 | find_tags_by_class, |
555 | find_tag_by_id, |
556 | + get_feedback_messages, |
557 | ) |
558 | from lp.testing.publication import test_traverse |
559 | from lp.testing.views import ( |
560 | @@ -559,6 +572,146 @@ |
561 | self.assertSnapProcessors(snap, ["386", "armhf"]) |
562 | |
563 | |
564 | +class TestSnapAuthorizeView(BrowserTestCase): |
565 | + |
566 | + layer = LaunchpadFunctionalLayer |
567 | + |
568 | + def setUp(self): |
569 | + super(TestSnapAuthorizeView, self).setUp() |
570 | + self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS)) |
571 | + self.person = self.factory.makePerson( |
572 | + name="test-person", displayname="Test Person") |
573 | + self.distroseries = self.factory.makeUbuntuDistroSeries() |
574 | + with admin_logged_in(): |
575 | + self.snappyseries = self.factory.makeSnappySeries( |
576 | + usable_distro_series=[self.distroseries]) |
577 | + self.snap = self.factory.makeSnap( |
578 | + registrant=self.person, owner=self.person, |
579 | + distroseries=self.distroseries, store_upload=True, |
580 | + store_series=self.snappyseries, |
581 | + store_name=self.factory.getUniqueUnicode()) |
582 | + |
583 | + def assertRequestsAuthorization(self, snap, func, *args, **kwargs): |
584 | + owner = snap.owner |
585 | + root_macaroon = Macaroon() |
586 | + root_macaroon.add_third_party_caveat( |
587 | + urlsplit(config.launchpad.openid_provider_root).netloc, '', |
588 | + 'dummy') |
589 | + root_macaroon_raw = root_macaroon.serialize() |
590 | + |
591 | + @all_requests |
592 | + def handler(url, request): |
593 | + self.request = request |
594 | + return { |
595 | + "status_code": 200, |
596 | + "content": {"macaroon": root_macaroon_raw}, |
597 | + } |
598 | + |
599 | + self.pushConfig("snappy", store_url="http://sca.example/") |
600 | + with HTTMock(handler): |
601 | + ret = func(*args, **kwargs) |
602 | + self.assertThat(self.request, MatchesStructure.byEquality( |
603 | + url="http://sca.example/dev/api/acl/", method="POST")) |
604 | + with person_logged_in(owner): |
605 | + expected_body = { |
606 | + "packages": [{ |
607 | + "name": snap.store_name, |
608 | + "series": snap.store_series.name, |
609 | + }], |
610 | + "permissions": ["package_upload"], |
611 | + } |
612 | + self.assertEqual(expected_body, json.loads(self.request.body)) |
613 | + self.assertEqual({"root": root_macaroon_raw}, snap.store_secrets) |
614 | + return ret |
615 | + |
616 | + def test_requestAuthorization(self): |
617 | + def request_authorization(): |
618 | + with person_logged_in(self.snap.owner): |
619 | + return SnapAuthorizeView.requestAuthorization( |
620 | + self.snap, LaunchpadTestRequest()) |
621 | + |
622 | + login_url = self.assertRequestsAuthorization( |
623 | + self.snap, request_authorization) |
624 | + self.assertEqual( |
625 | + canonical_url(self.snap) + |
626 | + "/+authorize/+login?macaroon_caveat_id=dummy&" |
627 | + "discharge_macaroon_action=field.actions.complete&" |
628 | + "discharge_macaroon_field=field.discharge_macaroon", |
629 | + login_url) |
630 | + |
631 | + def test_unauthorized(self): |
632 | + # A user without edit access cannot authorize snap package uploads. |
633 | + self.useFixture(FakeLogger()) |
634 | + other_person = self.factory.makePerson() |
635 | + self.assertRaises( |
636 | + Unauthorized, self.getUserBrowser, |
637 | + canonical_url(self.snap) + "/+authorize", user=other_person) |
638 | + |
639 | + def test_begin_authorization(self): |
640 | + # With no special form actions, we return a form inviting the user |
641 | + # to begin authorization. This allows (re-)authorizing uploads of |
642 | + # an existing snap package without having to edit it. |
643 | + snap_url = canonical_url(self.snap) |
644 | + |
645 | + def begin_authorization(): |
646 | + browser = self.getNonRedirectingBrowser( |
647 | + url=snap_url + "/+authorize", user=self.snap.owner) |
648 | + return self.assertRaises( |
649 | + HTTPError, browser.getControl("Begin authorization").click) |
650 | + |
651 | + redirection = self.assertRequestsAuthorization( |
652 | + self.snap, begin_authorization) |
653 | + self.assertEqual(303, redirection.code) |
654 | + self.assertEqual( |
655 | + snap_url + "/+authorize/+login?macaroon_caveat_id=dummy&" |
656 | + "discharge_macaroon_action=field.actions.complete&" |
657 | + "discharge_macaroon_field=field.discharge_macaroon", |
658 | + redirection.hdrs["Location"]) |
659 | + |
660 | + def test_complete_authorization_missing_discharge_macaroon(self): |
661 | + # If the form does not include a discharge macaroon, the "complete" |
662 | + # action fails. |
663 | + with person_logged_in(self.snap.owner): |
664 | + self.snap.store_secrets = {"root": "root"} |
665 | + transaction.commit() |
666 | + form = {"field.actions.complete": "1"} |
667 | + view = create_initialized_view( |
668 | + self.snap, "+authorize", form=form, method="POST", |
669 | + principal=self.snap.owner) |
670 | + html = view() |
671 | + self.assertEqual( |
672 | + "Uploads of %s to the store were not authorized." % |
673 | + self.snap.name, |
674 | + get_feedback_messages(html)[1]) |
675 | + self.assertNotIn("discharge", self.snap.store_secrets) |
676 | + |
677 | + def test_complete_authorization(self): |
678 | + # If the form includes a discharge macaroon, the "complete" action |
679 | + # succeeds and records the new secrets. |
680 | + with person_logged_in(self.snap.owner): |
681 | + self.snap.store_secrets = {"root": "root"} |
682 | + transaction.commit() |
683 | + form = { |
684 | + "field.actions.complete": "1", |
685 | + "field.discharge_macaroon": "discharge", |
686 | + } |
687 | + view = create_initialized_view( |
688 | + self.snap, "+authorize", form=form, method="POST", |
689 | + principal=self.snap.owner) |
690 | + self.assertEqual("", view()) |
691 | + self.assertEqual(302, view.request.response.getStatus()) |
692 | + self.assertEqual( |
693 | + canonical_url(self.snap), |
694 | + view.request.response.getHeader("Location")) |
695 | + self.assertEqual( |
696 | + "Uploads of %s to the store are now authorized." % |
697 | + self.snap.name, |
698 | + view.request.response.notifications[0].message) |
699 | + self.assertEqual( |
700 | + {"root": "root", "discharge": "discharge"}, |
701 | + self.snap.store_secrets) |
702 | + |
703 | + |
704 | class TestSnapDeleteView(BrowserTestCase): |
705 | |
706 | layer = LaunchpadFunctionalLayer |
707 | |
708 | === added file 'lib/lp/snappy/templates/snap-authorize.pt' |
709 | --- lib/lp/snappy/templates/snap-authorize.pt 1970-01-01 00:00:00 +0000 |
710 | +++ lib/lp/snappy/templates/snap-authorize.pt 2016-05-19 02:03:28 +0000 |
711 | @@ -0,0 +1,24 @@ |
712 | +<html |
713 | + xmlns="http://www.w3.org/1999/xhtml" |
714 | + xmlns:tal="http://xml.zope.org/namespaces/tal" |
715 | + xmlns:metal="http://xml.zope.org/namespaces/metal" |
716 | + xmlns:i18n="http://xml.zope.org/namespaces/i18n" |
717 | + metal:use-macro="view/macro:page/main_only" |
718 | + i18n:domain="launchpad"> |
719 | +<body> |
720 | + |
721 | +<div metal:fill-slot="main"> |
722 | + <div metal:use-macro="context/@@launchpad_form/form"> |
723 | + <p metal:fill-slot="extra_info"> |
724 | + The login service will prompt you to authorize this request. |
725 | + </p> |
726 | + <metal:suppress-superfluous-widgets fill-slot="widgets" /> |
727 | + <div class="actions" metal:fill-slot="buttons"> |
728 | + <input tal:replace="structure view/begin_action/render" /> |
729 | + or <a tal:attributes="href view/cancel_url">Cancel</a> |
730 | + </div> |
731 | + </div> |
732 | +</div> |
733 | + |
734 | +</body> |
735 | +</html> |
736 | |
737 | === modified file 'setup.py' |
738 | --- setup.py 2016-05-03 16:38:52 +0000 |
739 | +++ setup.py 2016-05-19 02:03:28 +0000 |
740 | @@ -78,6 +78,7 @@ |
741 | 'pgbouncer', |
742 | 'psycopg2', |
743 | 'pyasn1', |
744 | + 'pymacaroons', |
745 | 'pystache', |
746 | 'python-memcached', |
747 | 'python-openid', |
748 | |
749 | === modified file 'versions.cfg' |
750 | --- versions.cfg 2016-05-12 10:53:22 +0000 |
751 | +++ versions.cfg 2016-05-19 02:03:28 +0000 |
752 | @@ -67,6 +67,7 @@ |
753 | lazr.sshserver = 0.1.3 |
754 | lazr.testing = 0.1.1 |
755 | lazr.uri = 1.0.3 |
756 | +libnacl = 1.3.6 |
757 | lpjsmin = 0.5 |
758 | manuel = 1.7.2 |
759 | Markdown = 2.3.1 |
760 | @@ -94,6 +95,7 @@ |
761 | Pygments = 1.6 |
762 | pygpgme = 0.2 |
763 | pyinotify = 0.9.4 |
764 | +pymacaroons = 0.9.2 |
765 | pymongo = 2.1.1 |
766 | pyOpenSSL = 0.13 |
767 | pystache = 0.5.3 |