Merge lp:~cjwatson/launchpad/snap-authorize-view into lp:launchpad

Proposed by Colin Watson
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
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://code.launchpad.net/~cjwatson/meta-lp-deps/libsodium/+merge/294316 deployed to buildbot and production systems and a corresponding dependencies commit before we can land this.

To post a comment you must log in.
William Grant (wgrant) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/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