Merge lp:~wgrant/launchpad/sca-api into lp:launchpad
- sca-api
- Merge into devel
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 |
Related bugs: |
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.
Description of the change
Implement PersonSet.
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
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 |
Thanks William. Looks great - I'll see if we can get the sca work scheduled so we can close the private xmlrpc access.