Merge lp:~kelvin.li/canonical-identity-provider/error_normalization_tokens_discharge into lp:canonical-identity-provider/release

Proposed by Kelvin Li
Status: Merged
Approved by: Kelvin Li
Approved revision: no longer in the source branch.
Merge reported by: Ubuntu One Auto Pilot
Merged at revision: not available
Proposed branch: lp:~kelvin.li/canonical-identity-provider/error_normalization_tokens_discharge
Merge into: lp:canonical-identity-provider/release
Diff against target: 582 lines (+319/-59)
4 files modified
docs/api.txt (+116/-0)
src/api/v20/handlers.py (+28/-24)
src/api/v20/tests/test_handlers.py (+22/-5)
src/api/v20/utils.py (+153/-30)
To merge this branch: bzr merge lp:~kelvin.li/canonical-identity-provider/error_normalization_tokens_discharge
Reviewer Review Type Date Requested Status
Facundo Batista (community) Approve
Review via email: mp+303519@code.launchpad.net

Commit message

update the snap related 2 endpoints to use the normalization error format.

Description of the change

update the snap related 2 endpoints to use the normalization error format.

To post a comment you must log in.
Revision history for this message
Facundo Batista (facundo) wrote :

Please include a general format assertion at test case level as I did in SCA, so we're sure ALL error responses are in the new format.

Also, added some notes and stuff to fix in the inline comments.

Thanks!

review: Needs Fixing
Revision history for this message
Kelvin Li (kelvin.li) wrote :

Thanks for the review, after putting the general format assertion in the test cases,
I fixed the related new issues in test cases. also applied your comments into the code.

Revision history for this message
Facundo Batista (facundo) wrote :

Just for completeness, commenting here what we discussed in IRC: the "code" in the error_list items must not be the current ones, but some specific codes

Revision history for this message
Kelvin Li (kelvin.li) wrote :

updated the standard error code in the response and related api doc.

Revision history for this message
Facundo Batista (facundo) wrote :

As discussed in IRC: please include only the errors that SSO is using, and make the relationship between each error and its code something of the ERRORS structure itself.

Thanks!

Revision history for this message
Kelvin Li (kelvin.li) wrote :

1. Clean the standard error codes to keep the ones SSO is using;
2. Update the API doc;
3. Move the error code into the ERRORS dict to create the relation mapping to the old code;

Revision history for this message
Facundo Batista (facundo) wrote :

A couple of inline questions, thanks!

Revision history for this message
Kelvin Li (kelvin.li) wrote :

Hi, Please see my replies in below inline.

Revision history for this message
Facundo Batista (facundo) wrote :

Looks fine, thanks!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'docs/api.txt'
2--- docs/api.txt 2015-10-22 17:07:12 +0000
3+++ docs/api.txt 2016-09-02 11:35:34 +0000
4@@ -97,3 +97,119 @@
5 and a reason why they failed.
6
7 See the relevant documentation for errors specific to each api.
8+
9+
10+New format Errors
11+=================
12+
13+The *Snap Packages API* uses conventional HTTP response codes to indicate
14+success or failure of an API request.
15+
16+In general, codes in the 2xx range indicate success, codes in the 4xx range
17+indicate an error that resulted from the provided information (e.g. a
18+required parameter was missing) and codes in the 5xx range indicate an
19+error with our servers.
20+
21+Here is detailed the format for API responses that end in error.
22+
23+This applies to all the 4xx responses, but also to some 5xx ones (if
24+possible, the client should be prepared to handle 5xx responses with no
25+informational body). Note that this structure format does not apply to 2xx and
26+3xx responses, as those are note errors.
27+
28+.. important::
29+
30+ Not all API endpoints are migrated yet to this new error format
31+
32+
33+Format
34+------
35+
36+An error response body will contain the following field:
37+
38+- ``error_list``: a list of one or several items (never empty), each item
39+ described by...
40+
41+ - ``message``: a text in English describing the error that happened,
42+ ready to show to the user.
43+
44+ - ``code``: a short (but representative) string indicating concisely the
45+ error; it's aimed for clients to take specific actions and react to the
46+ problem. See below for the list of existing codes.
47+
48+
49+Additionally and for backwards compatibility reasons, some other fields may
50+be present as well, but are considered deprecated and will be removed in
51+the near future.
52+
53+No status or success indication is returned inside the response body, the
54+client should react properly to the received HTTP return code according to
55+its `well stablished semantics
56+<http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html>`_.
57+
58+
59+Codes
60+-----
61+
62+These are the codes used in the response and their meanings:
63+
64+- ``account-not-ready``: the account is deactivated, suspended or account email
65+ is not valildated.
66+
67+- ``bad-request``: there is a problem in the structure of the request.
68+
69+- ``internal-server-error``: some unexpected problem server side; this will be
70+ the code in all 5xx cases.
71+
72+- ``invalid-credentials``: the credentials for the authentication are not valid
73+ (e.g.: the username, password are not correct, the 2 factor is needed, etc).
74+
75+- ``invalid-data``: the data is incorrect or corrupt.
76+
77+- ``permission-required``: the macaroon authorization is missing in
78+ the received request or not enough for it to be fulfilled.
79+
80+- ``required``: the field in the request can't be empty or null.
81+
82+- ``resource-not-found``: one or more fields are included to specify a
83+ resource, but it is not found in the Store.
84+
85+- ``resource-not-ready``: the request actions on a resource that is not
86+ ready yet for that purpose; normally something else would need to be done
87+ first on the resource before this request can be repeated.
88+
89+- ``user-not-ready``: the user is not ready to issue the received request;
90+ normally some actions would need to be done in the user account before
91+ repeating the request.
92+
93+
94+Examples
95+--------
96+
97+A simple error:
98+
99+.. sourcecode:: json
100+
101+ {
102+ "error_list": [{
103+ "message": "The field 'expiration' must be an integer",
104+ "code": "invalid-field"
105+ }]
106+ }
107+A multiple error:
108+
109+.. sourcecode:: json
110+
111+ {
112+ "error_list": [{
113+ "message": "The 'foo' field is required",
114+ "code": "missing-field"
115+ }, {
116+ "message": "The 'bar' field is required",
117+ "code": "missing-field"
118+ }, {
119+ "message": "The 'baz' field must not be empty",
120+ "code": "invalid-field"
121+ }]
122+ }
123+
124
125=== modified file 'src/api/v20/handlers.py'
126--- src/api/v20/handlers.py 2016-08-11 22:06:36 +0000
127+++ src/api/v20/handlers.py 2016-09-02 11:35:34 +0000
128@@ -94,13 +94,14 @@
129 statsd.timing('view.{module}.{name}.{method}.{suffix}'.format(**data), ms)
130
131
132-def valid_email(email):
133+def valid_email(email, new_style=False):
134 if email is None:
135- return errors.INVALID_DATA(email=[FIELD_REQUIRED])
136+ return errors.INVALID_DATA(email=[FIELD_REQUIRED], new_style=new_style)
137+
138 try:
139 validate_email(email)
140 except ValidationError as e:
141- result = errors.INVALID_DATA(email=e.messages)
142+ result = errors.INVALID_DATA(email=e.messages, new_style=new_style)
143 else:
144 result = email
145 return result
146@@ -110,8 +111,8 @@
147 return openid
148
149
150-def get_email_from_request_data(request):
151- return valid_email(request.data.get('email'))
152+def get_email_from_request_data(request, new_style):
153+ return valid_email(request.data.get('email'), new_style)
154
155
156 def get_token_from_args(request, token):
157@@ -373,7 +374,7 @@
158
159 @require_mime('json')
160 @throttle(extra_callback=get_email_from_request_data)
161- def create(self, request):
162+ def create(self, request, new_style=False):
163 data = request.data
164 try:
165 email = data['email']
166@@ -444,9 +445,10 @@
167
168 @require_mime('json')
169 @throttle(extra_callback=get_email_from_request_data)
170- def create(self, request):
171+ def create(self, request, new_style=True):
172+
173 if settings.CRYPTO_SSO_PRIVKEY is None:
174- return errors.RESOURCE_NOT_FOUND()
175+ return errors.RESOURCE_NOT_FOUND(new_style=new_style)
176
177 data = request.data
178 try:
179@@ -455,7 +457,7 @@
180 except KeyError:
181 expected = {'email', 'password'}
182 missing = dict((k, [FIELD_REQUIRED]) for k in expected - set(data))
183- return errors.INVALID_DATA(**missing)
184+ return errors.INVALID_DATA(new_style=new_style, **missing)
185
186 if 'macaroon' in data:
187 simple_macaroon = True
188@@ -473,24 +475,24 @@
189 else:
190 # let's flag the new one only, not the deprecated ones
191 missing = {'caveat_id': [FIELD_REQUIRED]}
192- return errors.INVALID_DATA(**missing)
193+ return errors.INVALID_DATA(new_style=new_style, **missing)
194
195 account = None
196 response = None
197 try:
198 account = authenticate_user(email, password, request=request)
199 except AccountSuspended:
200- response = errors.ACCOUNT_SUSPENDED()
201+ response = errors.ACCOUNT_SUSPENDED(new_style=new_style)
202 except AccountDeactivated:
203- response = errors.ACCOUNT_DEACTIVATED()
204+ response = errors.ACCOUNT_DEACTIVATED(new_style=new_style)
205 except EmailInvalidated:
206- response = errors.EMAIL_INVALIDATED()
207+ response = errors.EMAIL_INVALIDATED(new_style=new_style)
208 except PasswordPolicyError as e:
209 root_url = request.build_absolute_uri('/')
210 response = errors.PASSWORD_POLICY_ERROR(
211- reason=unicode(e), location=root_url)
212+ reason=unicode(e), location=root_url, new_style=new_style)
213 except AuthenticationError:
214- response = errors.INVALID_CREDENTIALS()
215+ response = errors.INVALID_CREDENTIALS(new_style=new_style)
216
217 if account is not None:
218 otp = data.get('otp')
219@@ -499,9 +501,9 @@
220 twofactor.authenticate(account, otp)
221 except AuthenticationError:
222 account = None
223- response = errors.TWOFACTOR_FAILURE()
224+ response = errors.TWOFACTOR_FAILURE(new_style=new_style)
225 elif account.twofactor_required:
226- response = errors.TWOFACTOR_REQUIRED()
227+ response = errors.TWOFACTOR_REQUIRED(new_style=new_style)
228
229 if account is None:
230 # track failed login attempt
231@@ -525,9 +527,10 @@
232 account, root_macaroon_raw)
233 all_discharges.append((mac_id, discharge.serialize()))
234 except ValidationError:
235- return errors.INVALID_DATA()
236+ return errors.INVALID_DATA(new_style=new_style)
237+
238 except AuthenticationError:
239- return errors.INVALID_CREDENTIALS()
240+ return errors.INVALID_CREDENTIALS(new_style=new_style)
241
242 response = rc.ALL_OK
243 if simple_macaroon:
244@@ -546,24 +549,25 @@
245
246 @require_mime('json')
247 def create(self, request):
248+
249 if settings.CRYPTO_SSO_PRIVKEY is None:
250- return errors.RESOURCE_NOT_FOUND()
251+ return errors.RESOURCE_NOT_FOUND(new_style=True)
252
253 data = request.data
254 try:
255 discharge_macaroon_raw = data['discharge_macaroon']
256 except KeyError:
257 missing = {'discharge_macaroon': [FIELD_REQUIRED]}
258- return errors.INVALID_DATA(**missing)
259+ return errors.INVALID_DATA(new_style=True, **missing)
260
261 try:
262 new_discharge = auth.refresh_discharge(discharge_macaroon_raw)
263 except ValidationError:
264- return errors.INVALID_DATA()
265+ return errors.INVALID_DATA(new_style=True)
266 except AccountDeactivated:
267- return errors.ACCOUNT_DEACTIVATED()
268+ return errors.ACCOUNT_DEACTIVATED(new_style=True)
269 except AuthenticationError:
270- return errors.INVALID_CREDENTIALS()
271+ return errors.INVALID_CREDENTIALS(new_style=True)
272
273 response = rc.ALL_OK
274 response.content = dict(discharge_macaroon=new_discharge.serialize())
275
276=== modified file 'src/api/v20/tests/test_handlers.py'
277--- src/api/v20/tests/test_handlers.py 2016-08-11 22:06:36 +0000
278+++ src/api/v20/tests/test_handlers.py 2016-09-02 11:35:34 +0000
279@@ -25,6 +25,7 @@
280 from timeline import Timeline
281
282 from api.v20 import handlers, whitelist
283+from api.v20.utils import install_error_format_checker
284 from identityprovider.auth import build_discharge_macaroon
285 from identityprovider.login import PasswordPolicyError
286 from identityprovider.models import (
287@@ -2506,6 +2507,10 @@
288
289 url = reverse('api-macaroon-discharge')
290
291+ def setUp(self):
292+ super(MacaroonDischargeHandlerTestCase, self).setUp()
293+ install_error_format_checker(self)
294+
295 def do_post(self, data=None, expected_status_code=200,
296 content_type='application/json', **kwargs):
297 if data is None:
298@@ -2564,7 +2569,13 @@
299 self.assertEqual(json_body, {
300 'code': 'INVALID_DATA',
301 'extra': {'email': [handlers.FIELD_REQUIRED]},
302- 'message': 'Invalid request data'},
303+ 'message': 'Invalid request data',
304+ 'error_list': [{
305+ 'code': 'invalid-data',
306+ 'extra': {'email': [handlers.FIELD_REQUIRED]},
307+ 'message': 'Invalid request data'
308+ }],
309+ },
310 )
311 self.assertEqual(self.login_failed_calls, [])
312
313@@ -2791,6 +2802,7 @@
314 unbound_discharge)
315
316 self.data = {'discharge_macaroon': unbound_discharge.serialize()}
317+ install_error_format_checker(self)
318
319 def do_post(self, data=None, expected_status_code=200,
320 content_type='application/json', **kwargs):
321@@ -2822,12 +2834,17 @@
322
323 def test_required_parameters_discharge(self):
324 json_body = self.do_post(expected_status_code=400, data={})
325-
326- self.assertEqual(json_body, {
327+ expected = {
328 'code': 'INVALID_DATA',
329 'extra': {'discharge_macaroon': [handlers.FIELD_REQUIRED]},
330- 'message': 'Invalid request data'},
331- )
332+ 'message': 'Invalid request data',
333+ 'error_list': [{
334+ 'message': 'Invalid request data',
335+ 'code': 'invalid-data',
336+ 'extra': {'discharge_macaroon': ['Field required']}
337+ }]
338+ }
339+ self.assertEqual(json_body, expected)
340 self.assertEqual(self.login_failed_calls, [])
341
342 def test_account_missing(self):
343
344=== modified file 'src/api/v20/utils.py'
345--- src/api/v20/utils.py 2016-08-04 12:03:24 +0000
346+++ src/api/v20/utils.py 2016-09-02 11:35:34 +0000
347@@ -17,12 +17,13 @@
348 # response from inside the API are always expected to have a JSON
349 # content
350 msg = '%s: %s' % (exception.__class__.__name__, unicode(exception))
351- response = api_error(status=500, code='SERVER_ERROR', message=msg)
352+ response = api_error(status=500, code='SERVER_ERROR', message=msg,
353+ error_code=ErrorCode.INTERNAL_ERROR)
354
355 return response
356
357
358-def api_error(status, code, message, extra=None):
359+def api_error(status, code, message, error_code, extra=None):
360 if extra is None:
361 extra = {}
362
363@@ -39,58 +40,128 @@
364 'message': unicode(message),
365 'extra': extra_data,
366 }
367+
368+ # check if we're going to use the new style error format
369+ new_style = extra_data.pop('new_style', False)
370+ if new_style:
371+ error_list_data = {
372+ 'code': error_code,
373+ 'message': data['message'],
374+ }
375+ if data['extra']:
376+ error_list_data['extra'] = data['extra']
377+ data['error_list'] = [error_list_data]
378+
379 response = HttpResponse(content=json.dumps(data), status=status,
380 content_type='application/json; charset=utf-8')
381 response['Vary'] = 'Accept'
382 return response
383
384
385+class ErrorCode:
386+ ACCOUNT_NOT_READY = 'account-not-ready'
387+ BAD_REQUEST = 'bad-request'
388+ INTERNAL_ERROR = 'internal-server-error'
389+ INVALID_DATA = 'invalid-data'
390+ INVALID_CREDENTIALS = 'invalid-credentials'
391+ INVALID_REQUEST = 'invalid-request'
392+ PERMISSION_REQUIRED = 'permission-required'
393+ REQUIRED = 'field-required'
394+ RESOURCE_NOT_FOUND = 'resource-not-found'
395+ RESOURCE_NOT_READY = 'resource-not-ready'
396+
397+ def __init__(self):
398+ self._codes = {getattr(self, code) for code in dir(self)
399+ if code[0] != "_" and code != 'is_valid'}
400+
401+ def is_valid(self, code):
402+ return code in self._codes
403+
404+
405 ERRORS = dict(
406- INVALID_DATA=(400, _("Invalid request data")),
407+ INVALID_DATA=(400, _("Invalid request data"), ErrorCode.INVALID_DATA),
408
409 CAPTCHA_REQUIRED=(
410- 401, _('A captcha challenge is required to complete the request.')),
411- INVALID_CREDENTIALS=(401, _('Provided email/password is not correct.')),
412- TWOFACTOR_REQUIRED=(401, _("2-factor authentication required.")),
413+ 401, _('A captcha challenge is required to complete the request.'),
414+ ErrorCode.REQUIRED),
415+
416+ INVALID_CREDENTIALS=(
417+ 401, _('Provided email/password is not correct.'),
418+ ErrorCode.INVALID_CREDENTIALS),
419+
420+ TWOFACTOR_REQUIRED=(
421+ 401, _("2-factor authentication required."),
422+ ErrorCode.REQUIRED),
423
424 ACCOUNT_SUSPENDED=(
425 403, _('Your account has been suspended. Please contact login '
426- 'support to re-enable it')),
427+ 'support to re-enable it'),
428+ ErrorCode.ACCOUNT_NOT_READY),
429+
430 ACCOUNT_DEACTIVATED=(
431 403, _('Your account has been deactivated. To reactivate it, '
432- 'please reset your password')),
433+ 'please reset your password'),
434+ ErrorCode.ACCOUNT_NOT_READY),
435+
436 EMAIL_INVALIDATED=(
437 403, _("This email address has been invalidated. Please contact "
438- "login support.")),
439- CAN_NOT_RESET_PASSWORD=(403, _('Can not reset password. Please contact '
440- 'login support')),
441- CAPTCHA_FAILURE=(403, _('Failed response to captcha challenge.')),
442- TOO_MANY_TOKENS=(403, _('Too many non-consumed tokens exist. Further '
443- 'token creation is not allowed until existing '
444- 'tokens are consumed.')),
445+ "login support."),
446+ ErrorCode.ACCOUNT_NOT_READY),
447+
448+ CAN_NOT_RESET_PASSWORD=(
449+ 403, _('Can not reset password. Please contact login support'),
450+ ErrorCode.INVALID_REQUEST),
451+
452+ CAPTCHA_FAILURE=(
453+ 403, _('Failed response to captcha challenge.'),
454+ ErrorCode.RESOURCE_NOT_FOUND),
455+
456+ TOO_MANY_TOKENS=(
457+ 403, _('Too many non-consumed tokens exist. Further '
458+ 'token creation is not allowed until existing '
459+ 'tokens are consumed.'),
460+ ErrorCode.RESOURCE_NOT_READY),
461+
462 TWOFACTOR_FAILURE=(
463- 403, _("The provided 2-factor key is not recognised.")),
464- PASSWORD_POLICY_ERROR = (403, _("Your password doesn't meet "
465- "security constraints.")),
466- FORBIDDEN=(403, _("Insufficient permissions.")),
467-
468- RESOURCE_NOT_FOUND=(404, _("The resource requested was not found.")),
469-
470- ALREADY_REGISTERED=(409, _("The email address is already registered")),
471-
472- TOO_MANY_REQUESTS=(429, _('Too many requests. Please try again later.')),
473-
474- CAPTCHA_ERROR=(502, _('Unable to get a valid response from '
475- 'reCaptcha service')),
476+ 403, _("The provided 2-factor key is not recognised."),
477+ ErrorCode.INVALID_CREDENTIALS),
478+
479+ PASSWORD_POLICY_ERROR = (
480+ 403, _("Your password doesn't meet security constraints."),
481+ ErrorCode.INVALID_CREDENTIALS),
482+
483+ FORBIDDEN=(
484+ 403, _("Insufficient permissions."),
485+ ErrorCode.PERMISSION_REQUIRED),
486+
487+ RESOURCE_NOT_FOUND=(
488+ 404, _("The resource requested was not found."),
489+ ErrorCode.RESOURCE_NOT_FOUND),
490+
491+ ALREADY_REGISTERED=(
492+ 409, _("The email address is already registered"),
493+ ErrorCode.RESOURCE_NOT_READY),
494+
495+ TOO_MANY_REQUESTS=(
496+ 429, _('Too many requests. Please try again later.'),
497+ ErrorCode.BAD_REQUEST),
498+
499+ CAPTCHA_ERROR=(
500+ 502, _('Unable to get a valid response from reCaptcha service'),
501+ ErrorCode.INTERNAL_ERROR),
502+
503+ SERVER_ERROR=(
504+ 500, _('internal server error'),
505+ ErrorCode.INTERNAL_ERROR)
506 )
507
508
509 class Errors(object):
510 def wrap(self, code):
511- status, message = ERRORS[code]
512+ status, message, error_code = ERRORS[code]
513
514 def wrapper(**extra):
515- return api_error(status, code, message, extra)
516+ return api_error(status, code, message, error_code, extra)
517 wrapper.__name__ = code
518 return wrapper
519
520@@ -100,6 +171,7 @@
521 else:
522 raise AttributeError
523
524+
525 errors = Errors()
526
527
528@@ -172,3 +244,54 @@
529 token_secret=token.secret,
530 ))
531 return result
532+
533+
534+def assert_response_error_format(resp):
535+ """Check that the error format is ok."""
536+ if resp.status_code < 400:
537+ # not checked
538+ return
539+
540+ if resp.status_code >= 500 and resp['content-type'] == 'text/plain':
541+ # simple crashing
542+ return
543+
544+ # we have now a 4xx response or a 5xx with interesting body, verify it!
545+ assert resp['content-type'] == 'application/json; charset=utf-8', (
546+ "bad CT: " + repr(resp['content-type']))
547+ content = json.loads(resp.content)
548+
549+ error_list = content['error_list']
550+ assert error_list, "Empty error list"
551+
552+ ec = ErrorCode()
553+ for item in error_list:
554+ code = item['code']
555+ assert ec.is_valid(code), "Invalid code: %r" % (code,)
556+
557+ message = item['message']
558+ assert isinstance(message, basestring), "message must be a string"
559+
560+ if 'extra' in item:
561+ assert isinstance(item['extra'], dict), (
562+ "extra info must be a dict")
563+
564+
565+def install_error_format_checker(testcase):
566+ # put the ApiResource import here to avoid the following error
567+ # AttributeError: 'module' object has no attribute 'test_handlers'
568+ from api.v20.auth import ApiResource
569+ """Install an error format checker in the TestCase instance."""
570+ orig_method = ApiResource.__call__
571+
572+ def mitm(*a, **k):
573+ """Send resource/handler response for proper format assertion."""
574+ resp = orig_method(*a, **k)
575+ try:
576+ assert_response_error_format(resp)
577+ except Exception as err:
578+ testcase.fail("[Response Format Error] %s(%s)" % (
579+ err.__class__.__name__, err))
580+ return resp
581+
582+ testcase.patch(ApiResource, '__call__', new=mitm)