Merge lp:~wgrant/launchpad/sca-api into lp:launchpad

Proposed by William Grant
Status: Merged
Merged at revision: 17390
Proposed branch: lp:~wgrant/launchpad/sca-api
Merge into: lp:launchpad
Diff against target: 475 lines (+290/-13)
6 files modified
lib/lp/registry/browser/tests/test_person_webservice.py (+61/-1)
lib/lp/registry/interfaces/person.py (+25/-1)
lib/lp/registry/model/person.py (+57/-8)
lib/lp/registry/tests/test_personset.py (+138/-1)
lib/lp/services/identity/interfaces/account.py (+4/-1)
lib/lp/services/identity/interfaces/emailaddress.py (+5/-1)
To merge this branch: bzr merge lp:~wgrant/launchpad/sca-api
Reviewer Review Type Date Requested Status
Michael Nelson (community) Approve
Colin Watson (community) Approve
Review via email: mp+252420@code.launchpad.net

Commit message

Implement PersonSet.getOrCreateSoftwareCenterCustomer and export it on the webservice. The xmlrpc-private method is deprecated.

Description of the change

Implement PersonSet.getOrCreateSoftwareCenterCustomer and export it on the webservice.

It will replace the xmlrpc-private method of the same name with something that's a bit less insecure. SCA will no longer be able to compromise existing active accounts by attaching new OpenID identifiers or email addresses to them. It retains the ability to:

 - Look up an existing active account by OpenID identifier
 - Activate an unactivated account by OpenID identifier and with the given email address
 - Create a new account with the given OpenID identifier and email address

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) :
review: Approve
Revision history for this message
Michael Nelson (michael.nelson) wrote :

Thanks William. Looks great - I'll see if we can get the sca work scheduled so we can close the private xmlrpc access.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/registry/browser/tests/test_person_webservice.py'
2--- lib/lp/registry/browser/tests/test_person_webservice.py 2015-01-07 00:35:53 +0000
3+++ lib/lp/registry/browser/tests/test_person_webservice.py 2015-03-10 22:08:18 +0000
4@@ -1,8 +1,9 @@
5-# Copyright 2009 Canonical Ltd. This software is licensed under the
6+# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
7 # GNU Affero General Public License version 3 (see the file LICENSE).
8
9 __metaclass__ = type
10
11+from storm.store import Store
12 from testtools.matchers import Equals
13 from zope.component import getUtility
14 from zope.security.management import endInteraction
15@@ -14,6 +15,7 @@
16 )
17 from lp.registry.interfaces.teammembership import ITeamMembershipSet
18 from lp.services.identity.interfaces.account import AccountStatus
19+from lp.services.openid.model.openididentifier import OpenIdIdentifier
20 from lp.services.webapp.interfaces import OAuthPermission
21 from lp.testing import (
22 admin_logged_in,
23@@ -282,3 +284,61 @@
24 'identifier=http://login1.dev/%%2Bid/%s'
25 % person_openid,
26 api_version='devel').jsonBody()['name'])
27+
28+ def getOrCreateSoftwareCenterCustomer(self, user):
29+ webservice = webservice_for_person(
30+ user, permission=OAuthPermission.WRITE_PRIVATE)
31+ response = webservice.named_post(
32+ '/people', 'getOrCreateSoftwareCenterCustomer',
33+ openid_identifier='somebody',
34+ email_address='somebody@example.com', display_name='Somebody',
35+ api_version='devel')
36+ return response
37+
38+ def test_getOrCreateSoftwareCenterCustomer(self):
39+ # Software Center Agent (SCA) can get or create people by OpenID
40+ # identifier.
41+ with admin_logged_in():
42+ sca = getUtility(IPersonSet).getByName('software-center-agent')
43+ response = self.getOrCreateSoftwareCenterCustomer(sca)
44+ self.assertEqual('Somebody', response.jsonBody()['display_name'])
45+ with admin_logged_in():
46+ person = getUtility(IPersonSet).getByEmail('somebody@example.com')
47+ self.assertEqual('Somebody', person.displayname)
48+ self.assertEqual(
49+ ['somebody'],
50+ [oid.identifier for oid in person.account.openid_identifiers])
51+ self.assertEqual(
52+ 'somebody@example.com', person.preferredemail.email)
53+
54+ def test_getOrCreateSoftwareCenterCustomer_is_restricted(self):
55+ # The method may only be invoked by the ~software-center-agent
56+ # celebrity user, as it is security-sensitive.
57+ with admin_logged_in():
58+ random = self.factory.makePerson()
59+ response = self.getOrCreateSoftwareCenterCustomer(random)
60+ self.assertEqual(401, response.status)
61+
62+ def test_getOrCreateSoftwareCenterCustomer_rejects_email_conflicts(self):
63+ # An unknown OpenID identifier with a known email address causes
64+ # the request to fail with 409 Conflict, as we'd otherwise end
65+ # up linking the OpenID identifier to an existing account.
66+ with admin_logged_in():
67+ self.factory.makePerson(email='somebody@example.com')
68+ sca = getUtility(IPersonSet).getByName('software-center-agent')
69+ response = self.getOrCreateSoftwareCenterCustomer(sca)
70+ self.assertEqual(409, response.status)
71+
72+ def test_getOrCreateSoftwareCenterCustomer_rejects_suspended(self):
73+ # Suspended accounts are not returned.
74+ with admin_logged_in():
75+ existing = self.factory.makePerson(
76+ email='somebody@example.com',
77+ account_status=AccountStatus.SUSPENDED)
78+ oid = OpenIdIdentifier()
79+ oid.account = existing.account
80+ oid.identifier = u'somebody'
81+ Store.of(existing).add(oid)
82+ sca = getUtility(IPersonSet).getByName('software-center-agent')
83+ response = self.getOrCreateSoftwareCenterCustomer(sca)
84+ self.assertEqual(400, response.status)
85
86=== modified file 'lib/lp/registry/interfaces/person.py'
87--- lib/lp/registry/interfaces/person.py 2015-02-26 11:34:47 +0000
88+++ lib/lp/registry/interfaces/person.py 2015-03-10 22:08:18 +0000
89@@ -1,4 +1,4 @@
90-# Copyright 2009-2014 Canonical Ltd. This software is licensed under the
91+# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
92 # GNU Affero General Public License version 3 (see the file LICENSE).
93
94 """Person interfaces."""
95@@ -2173,6 +2173,30 @@
96 identifier has been suspended.
97 """
98
99+ @call_with(user=REQUEST_USER)
100+ @operation_parameters(
101+ openid_identifier=TextLine(
102+ title=_("OpenID identifier suffix"), required=True),
103+ email_address=TextLine(title=_("Email address"), required=True),
104+ display_name=TextLine(title=_("Display name"), required=True))
105+ @export_write_operation()
106+ @operation_for_version("devel")
107+ def getOrCreateSoftwareCenterCustomer(user, openid_identifier,
108+ email_address, display_name):
109+ """Restricted person creation API for Software Center Agent.
110+
111+ This method can only be called by Software Center Agent. It gets
112+ a person by OpenID identifier or creates a new Launchpad person
113+ from the OpenID identifier, email address and display name.
114+
115+ :param user: the `IPerson` performing the operation. Only the
116+ software-center-agent celebrity is allowed.
117+ :param openid_identifier: OpenID identifier suffix for the user.
118+ This is *not* the full URL, just the unique suffix portion.
119+ :param email_address: the email address of the user.
120+ :param full_name: the full name of the user.
121+ """
122+
123 @call_with(teamowner=REQUEST_USER)
124 @rename_parameters_as(
125 displayname='display_name', teamdescription='team_description',
126
127=== modified file 'lib/lp/registry/model/person.py'
128--- lib/lp/registry/model/person.py 2015-03-04 19:05:47 +0000
129+++ lib/lp/registry/model/person.py 2015-03-10 22:08:18 +0000
130@@ -268,6 +268,7 @@
131 INACTIVE_ACCOUNT_STATUSES,
132 )
133 from lp.services.identity.interfaces.emailaddress import (
134+ EmailAddressAlreadyTaken,
135 EmailAddressStatus,
136 IEmailAddress,
137 IEmailAddressSet,
138@@ -3317,8 +3318,26 @@
139 return IPerson(account)
140
141 def getOrCreateByOpenIDIdentifier(self, openid_identifier, email_address,
142- full_name, creation_rationale, comment):
143+ full_name, creation_rationale, comment,
144+ trust_email=True):
145 """See `IPersonSet`."""
146+ # trust_email is an internal flag used by
147+ # getOrCreateSoftwareCenterCustomer. We don't want SCA to be
148+ # able to use the API to associate arbitrary OpenID identifiers
149+ # and email addresses when that could compromise existing
150+ # accounts.
151+ #
152+ # To that end, if trust_email is not set then the given
153+ # email address will only be used to create an account. It will
154+ # never be used to look up an account, nor will it be added to
155+ # an existing account. This causes two additional cases to be
156+ # rejected: unknown OpenID identifier but known email address,
157+ # and deactivated account.
158+ #
159+ # Exempting account creation and activation from this rule opens
160+ # us to a potential account fixation attack, but the risk is
161+ # minimal.
162+
163 assert email_address is not None and full_name is not None, (
164 "Both email address and full name are required to create an "
165 "account.")
166@@ -3341,13 +3360,19 @@
167 # We don't know about the OpenID identifier yet, so try
168 # to match a person by email address, or as a last
169 # resort create a new one.
170- if email is not None:
171- person = email.person
172- else:
173+ if email is None:
174 person_set = getUtility(IPersonSet)
175 person, email = person_set.createPersonAndEmail(
176 email_address, creation_rationale, comment=comment,
177 displayname=full_name)
178+ elif trust_email:
179+ person = email.person
180+ else:
181+ # The email address originated from a source that's
182+ # not completely trustworth (eg. SCA), so we can't
183+ # use it to link the OpenID identifier to an
184+ # existing person.
185+ raise EmailAddressAlreadyTaken()
186
187 # It's possible that the email address is owned by a
188 # team. Reject the login attempt, and wait for the user
189@@ -3373,10 +3398,21 @@
190 person = IPerson(identifier.account, None)
191 assert person is not None, ('Received a personless account.')
192
193- if person.account.status == AccountStatus.SUSPENDED:
194+ status = person.account.status
195+ if status == AccountStatus.ACTIVE:
196+ # Account is active, so nothing to do.
197+ pass
198+ elif status == AccountStatus.SUSPENDED:
199 raise AccountSuspendedError(
200 "The account matching the identifier is suspended.")
201-
202+ elif not trust_email and status != AccountStatus.NOACCOUNT:
203+ # If the email address is not completely trustworthy
204+ # (ie. it comes from SCA) and the account has already
205+ # been used, then we don't want to proceed as we might
206+ # end up adding a malicious OpenID identifier to an
207+ # existing account.
208+ raise NameAlreadyTaken(
209+ "The account matching the identifier is inactive.")
210 elif person.account.status in [AccountStatus.DEACTIVATED,
211 AccountStatus.NOACCOUNT]:
212 removeSecurityProxy(person.account).reactivate(comment)
213@@ -3386,11 +3422,24 @@
214 removeSecurityProxy(person).setPreferredEmail(email)
215 db_updated = True
216 else:
217- # Account is active, so nothing to do.
218- pass
219+ raise AssertionError(
220+ "Unhandled account status: %r" % person.account.status)
221
222 return person, db_updated
223
224+ def getOrCreateSoftwareCenterCustomer(self, user, openid_identifier,
225+ email_address, display_name):
226+ """See `IPersonSet`."""
227+ if user != getUtility(ILaunchpadCelebrities).software_center_agent:
228+ raise Unauthorized()
229+ person, _ = getUtility(
230+ IPersonSet).getOrCreateByOpenIDIdentifier(
231+ openid_identifier, email_address, display_name,
232+ PersonCreationRationale.SOFTWARE_CENTER_PURCHASE,
233+ "when purchasing an application via Software Center.",
234+ trust_email=False)
235+ return person
236+
237 def newTeam(self, teamowner, name, displayname, teamdescription=None,
238 membership_policy=TeamMembershipPolicy.MODERATED,
239 defaultmembershipperiod=None, defaultrenewalperiod=None,
240
241=== modified file 'lib/lp/registry/tests/test_personset.py'
242--- lib/lp/registry/tests/test_personset.py 2015-01-07 00:35:53 +0000
243+++ lib/lp/registry/tests/test_personset.py 2015-03-10 22:08:18 +0000
244@@ -1,4 +1,4 @@
245-# Copyright 2009-2013 Canonical Ltd. This software is licensed under the
246+# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
247 # GNU Affero General Public License version 3 (see the file LICENSE).
248
249 """Tests for PersonSet."""
250@@ -9,6 +9,7 @@
251 from testtools.matchers import LessThan
252 import transaction
253 from zope.component import getUtility
254+from zope.security.interfaces import Unauthorized
255 from zope.security.proxy import removeSecurityProxy
256
257 from lp.code.tests.helpers import remove_all_sample_data_branches
258@@ -41,13 +42,17 @@
259 from lp.services.identity.interfaces.emailaddress import (
260 EmailAddressAlreadyTaken,
261 EmailAddressStatus,
262+ IEmailAddressSet,
263 InvalidEmailAddress,
264 )
265 from lp.services.identity.model.account import Account
266 from lp.services.identity.model.emailaddress import EmailAddress
267 from lp.services.openid.model.openididentifier import OpenIdIdentifier
268+from lp.services.webapp.interfaces import ILaunchBag
269 from lp.testing import (
270+ admin_logged_in,
271 ANONYMOUS,
272+ anonymous_logged_in,
273 login,
274 logout,
275 person_logged_in,
276@@ -590,3 +595,135 @@
277 u'other-openid-identifier' in [
278 identifier.identifier for identifier in removeSecurityProxy(
279 person.account).openid_identifiers])
280+
281+
282+class TestPersonSetGetOrCreateSoftwareCenterCustomer(TestCaseWithFactory):
283+
284+ layer = DatabaseFunctionalLayer
285+
286+ def setUp(self):
287+ super(TestPersonSetGetOrCreateSoftwareCenterCustomer, self).setUp()
288+ self.sca = getUtility(IPersonSet).getByName('software-center-agent')
289+
290+ def makeOpenIdIdentifier(self, account, identifier):
291+ openid_identifier = OpenIdIdentifier()
292+ openid_identifier.identifier = identifier
293+ openid_identifier.account = account
294+ return IStore(OpenIdIdentifier).add(openid_identifier)
295+
296+ def test_restricted_to_sca(self):
297+ # Only the software-center-agent celebrity can invoke this
298+ # privileged method.
299+ def do_it():
300+ return getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer(
301+ getUtility(ILaunchBag).user, u'somebody',
302+ 'somebody@example.com', 'Example')
303+ random = self.factory.makePerson()
304+ admin = self.factory.makePerson(
305+ member_of=[getUtility(IPersonSet).getByName('admins')])
306+
307+ # Anonymous, random or admin users can't invoke the method.
308+ with anonymous_logged_in():
309+ self.assertRaises(Unauthorized, do_it)
310+ with person_logged_in(random):
311+ self.assertRaises(Unauthorized, do_it)
312+ with person_logged_in(admin):
313+ self.assertRaises(Unauthorized, do_it)
314+
315+ with person_logged_in(self.sca):
316+ person = do_it()
317+ self.assertIsInstance(person, Person)
318+
319+ def test_finds_by_openid(self):
320+ # A Person with the requested OpenID identifier is returned.
321+ somebody = self.factory.makePerson()
322+ self.makeOpenIdIdentifier(somebody.account, u'somebody')
323+ with person_logged_in(self.sca):
324+ got = getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer(
325+ self.sca, u'somebody', 'somebody@example.com', 'Example')
326+ self.assertEqual(somebody, got)
327+
328+ # The email address doesn't get linked, as that could change how
329+ # future logins work.
330+ self.assertIs(
331+ None,
332+ getUtility(IEmailAddressSet).getByEmail('somebody@example.com'))
333+
334+ def test_creates_new(self):
335+ # If an unknown OpenID identifier and email address are
336+ # provided, a new account is created and returned.
337+ with person_logged_in(self.sca):
338+ made = getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer(
339+ self.sca, u'somebody', 'somebody@example.com', 'Example')
340+ with admin_logged_in():
341+ self.assertEqual('Example', made.displayname)
342+ self.assertEqual('somebody@example.com', made.preferredemail.email)
343+
344+ # The email address is linked, since it can't compromise an
345+ # account that is being created just for it.
346+ email = getUtility(IEmailAddressSet).getByEmail('somebody@example.com')
347+ self.assertEqual(made, email.person)
348+
349+ def test_activates_unactivated(self):
350+ # An unactivated account should be treated just like a new
351+ # account -- it gets activated with the given email address.
352+ somebody = self.factory.makePerson(
353+ email='existing@example.com',
354+ account_status=AccountStatus.NOACCOUNT)
355+ self.makeOpenIdIdentifier(somebody.account, u'somebody')
356+ self.assertEqual(AccountStatus.NOACCOUNT, somebody.account.status)
357+ with person_logged_in(self.sca):
358+ got = getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer(
359+ self.sca, u'somebody', 'somebody@example.com', 'Example')
360+ self.assertEqual(somebody, got)
361+ with admin_logged_in():
362+ self.assertEqual(AccountStatus.ACTIVE, somebody.account.status)
363+ self.assertEqual(
364+ 'somebody@example.com', somebody.preferredemail.email)
365+
366+ def test_fails_if_email_is_already_registered(self):
367+ # Only the OpenID identifier is used to look up an account. If
368+ # the OpenID identifier isn't already registered by the email
369+ # address is, the request is rejected to avoid potentially
370+ # adding an unwanted OpenID identifier to the address' account.
371+ #
372+ # The user must log into Launchpad directly first to register
373+ # their OpenID identifier.
374+ other = self.factory.makePerson(email='other@example.com')
375+ with person_logged_in(self.sca):
376+ self.assertRaises(
377+ EmailAddressAlreadyTaken,
378+ getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer,
379+ self.sca, u'somebody', 'other@example.com', 'Example')
380+
381+ # The email address stays with the old owner.
382+ email = getUtility(IEmailAddressSet).getByEmail('other@example.com')
383+ self.assertEqual(other, email.person)
384+
385+ def test_fails_if_account_is_suspended(self):
386+ # Suspended accounts cannot be returned.
387+ somebody = self.factory.makePerson()
388+ self.makeOpenIdIdentifier(somebody.account, u'somebody')
389+ with admin_logged_in():
390+ somebody.setAccountStatus(
391+ AccountStatus.SUSPENDED, None, "Go away!")
392+ with person_logged_in(self.sca):
393+ self.assertRaises(
394+ AccountSuspendedError,
395+ getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer,
396+ self.sca, u'somebody', 'somebody@example.com', 'Example')
397+
398+ def test_fails_if_account_is_deactivated(self):
399+ # We don't want to reactivate explicitly deactivated accounts,
400+ # nor do we want to potentially compromise them with a bad email
401+ # address.
402+ somebody = self.factory.makePerson()
403+ self.makeOpenIdIdentifier(somebody.account, u'somebody')
404+ with admin_logged_in():
405+ somebody.setAccountStatus(
406+ AccountStatus.DEACTIVATED, None, "Goodbye cruel world.")
407+ with person_logged_in(self.sca):
408+ self.assertRaises(
409+ NameAlreadyTaken,
410+ getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer,
411+ self.sca, u'somebody', 'somebody@example.com', 'Example')
412
413=== modified file 'lib/lp/services/identity/interfaces/account.py'
414--- lib/lp/services/identity/interfaces/account.py 2015-01-07 00:35:53 +0000
415+++ lib/lp/services/identity/interfaces/account.py 2015-03-10 22:08:18 +0000
416@@ -1,4 +1,4 @@
417-# Copyright 2009-2012 Canonical Ltd. This software is licensed under the
418+# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
419 # GNU Affero General Public License version 3 (see the file LICENSE).
420
421 """Account interfaces."""
422@@ -18,11 +18,13 @@
423 'INACTIVE_ACCOUNT_STATUSES',
424 ]
425
426+import httplib
427
428 from lazr.enum import (
429 DBEnumeratedType,
430 DBItem,
431 )
432+from lazr.restful.declarations import error_status
433 from zope.interface import (
434 Attribute,
435 Interface,
436@@ -40,6 +42,7 @@
437 from lp.services.fields import StrippedTextLine
438
439
440+@error_status(httplib.BAD_REQUEST)
441 class AccountSuspendedError(Exception):
442 """The account being accessed has been suspended."""
443
444
445=== modified file 'lib/lp/services/identity/interfaces/emailaddress.py'
446--- lib/lp/services/identity/interfaces/emailaddress.py 2013-01-07 02:40:55 +0000
447+++ lib/lp/services/identity/interfaces/emailaddress.py 2015-03-10 22:08:18 +0000
448@@ -1,4 +1,4 @@
449-# Copyright 2009-2012 Canonical Ltd. This software is licensed under the
450+# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
451 # GNU Affero General Public License version 3 (see the file LICENSE).
452
453 """EmailAddress interfaces."""
454@@ -12,11 +12,14 @@
455 'InvalidEmailAddress',
456 'VALID_EMAIL_STATUSES']
457
458+import httplib
459+
460 from lazr.enum import (
461 DBEnumeratedType,
462 DBItem,
463 )
464 from lazr.restful.declarations import (
465+ error_status,
466 export_as_webservice_entry,
467 exported,
468 )
469@@ -36,6 +39,7 @@
470 """The email address is not valid."""
471
472
473+@error_status(httplib.CONFLICT)
474 class EmailAddressAlreadyTaken(Exception):
475 """The email address is already registered in Launchpad."""
476