Merge lp:~aym-frikha/maas/add-apikey-name-api into lp:~maas-committers/maas/trunk

Proposed by Aymen Frikha
Status: Merged
Approved by: Mike Pontillo
Approved revision: no longer in the source branch.
Merged at revision: 5465
Proposed branch: lp:~aym-frikha/maas/add-apikey-name-api
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 528 lines (+336/-22)
6 files modified
src/maasserver/api/account.py (+61/-8)
src/maasserver/api/tests/test_api.py (+144/-5)
src/maasserver/management/commands/apikey.py (+52/-5)
src/maasserver/models/user.py (+6/-2)
src/maasserver/models/userprofile.py (+17/-2)
src/maasserver/tests/test_commands.py (+56/-0)
To merge this branch: bzr merge lp:~aym-frikha/maas/add-apikey-name-api
Reviewer Review Type Date Requested Status
Mike Pontillo (community) Approve
Review via email: mp+307740@code.launchpad.net

Commit message

API keys can have now names assigned to them.

 * Users are now able to generate new API with a name, using:
[API] maas ubuntu account create-authorisation-token name=TestKey
[CLI] maas apikey --username=ubuntu --generate --name TestKey

 * Users can also update existing API keys name, using:
[API] maas ubuntu account update-token-name token={{token key}} name=TestKey
[CLI] maas apikey --username=ubuntu --update {{token key}} --name TestKey

* Users are now able to list API keys with their names, using:
[API] maas ubuntu account list-authorisation-tokens
[CLI] maas apikey --username=ubuntu --with-names

If a new API key is generated without any name, the existing default name (which was never exposed to the user before) is assigned to it: "MAAS Consumer". (All existing keys will also have this name assigned to them.)

To post a comment you must log in.
Revision history for this message
Mike Pontillo (mpontillo) wrote :

The code looks generally good. I've got some comments below, mostly about the names of user-facing strings/documentation. (and some code style comments.)

review: Needs Fixing
Revision history for this message
Mike Pontillo (mpontillo) wrote :

I think this branch is looking really good. Just a few more minor nits below, mostly with regard to code style.

Revision history for this message
Mike Pontillo (mpontillo) wrote :

I just finished testing this branch and I think it's looking great. Here's a transcript.

http://paste.ubuntu.com/23289946/

There is one more thing I would change before I think it's okay to land this: we should swap the order of the output in "maas apikey --username admin --with-names". For example, right now I see:

MAAS consumer 9bU8Xw8GqvAEyrfjse:xXSyGnWYAZFWhEjPUq:G4fv6WnQ2KtH73c5gZ4byBqmAc3Dmzwg
Oh, I just remembered. kT85sFzqcNTckx6hNY:TUaQBgL4NK5enVkQec:czDGFca2kNV8EGrugjVSBAjcDc5zkLtQ

It would be easier for someone writing a script to parse this if we place the name *after* the key, since then you always know that everything after the first space is the key name (since key names can contain spaces).

So I would rather the output look like this:

9bU8Xw8GqvAEyrfjse:xXSyGnWYAZFWhEjPUq:G4fv6WnQ2KtH73c5gZ4byBqmAc3Dmzwg MAAS consumer
kT85sFzqcNTckx6hNY:TUaQBgL4NK5enVkQec:czDGFca2kNV8EGrugjVSBAjcDc5zkLtQ Oh, I just remembered.

It also looks better this way since the keys line up.

Thank you for your hard work and dedication on this.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/api/account.py'
2--- src/maasserver/api/account.py 2016-08-09 21:59:28 +0000
3+++ src/maasserver/api/account.py 2016-10-09 22:36:13 +0000
4@@ -5,7 +5,7 @@
5
6 __all__ = [
7 'AccountHandler',
8- ]
9+]
10
11 import http.client
12 import json
13@@ -15,10 +15,27 @@
14 operation,
15 OperationsHandler,
16 )
17-from maasserver.api.utils import get_mandatory_param
18+from maasserver.api.utils import (
19+ get_mandatory_param,
20+ get_optional_param,
21+)
22 from piston3.utils import rc
23
24
25+def _format_tokens(tokens):
26+ """Converts the given tokens into a list of dictionaries to represent them.
27+
28+ :param tokens: The result of `profile.get_authorisation_tokens()`.
29+ """
30+ return [
31+ {
32+ "name": token.consumer.name,
33+ "token": ":".join([token.consumer.key, token.key, token.secret])
34+ }
35+ for token in tokens
36+ ]
37+
38+
39 class AccountHandler(OperationsHandler):
40 """Manage the current logged-in user."""
41 api_doc_section_name = "Logged-in user"
42@@ -28,19 +45,22 @@
43 def create_authorisation_token(self, request):
44 """Create an authorisation OAuth token and OAuth consumer.
45
46- :return: a json dict with three keys: 'token_key',
47- 'token_secret' and 'consumer_key' (e.g.
48+ :param name: Optional name of the token that will be generated.
49+ :type name: unicode
50+ :return: a json dict with four keys: 'token_key',
51+ 'token_secret', 'consumer_key' and 'name'(e.g.
52 {token_key: 's65244576fgqs', token_secret: 'qsdfdhv34',
53- consumer_key: '68543fhj854fg'}).
54+ consumer_key: '68543fhj854fg', name: 'MAAS consumer'}).
55 :rtype: string (json)
56
57 """
58 profile = request.user.userprofile
59- consumer, token = profile.create_authorisation_token()
60+ consumer_name = get_optional_param(request.data, 'name')
61+ consumer, token = profile.create_authorisation_token(consumer_name)
62 auth_info = {
63 'token_key': token.key, 'token_secret': token.secret,
64- 'consumer_key': consumer.key,
65- }
66+ 'consumer_key': consumer.key, 'name': consumer.name
67+ }
68 return HttpResponse(
69 json.dumps(auth_info),
70 content_type='application/json; charset=utf-8',
71@@ -58,6 +78,39 @@
72 profile.delete_authorisation_token(token_key)
73 return rc.DELETED
74
75+ @operation(idempotent=False)
76+ def update_token_name(self, request):
77+ """Modify the consumer name of an authorisation OAuth token.
78+
79+ :param token: Can be the whole token or only the token key.
80+ :type token: unicode
81+ :param name: New name of the token.
82+ :type name: unicode
83+ """
84+ profile = request.user.userprofile
85+ token = get_mandatory_param(request.data, 'token')
86+ token_fields = token.split(":")
87+ if len(token_fields) == 3:
88+ token_key = token_fields[1]
89+ else:
90+ token_key = token
91+ consumer_name = get_mandatory_param(request.data, 'name')
92+ profile.modify_consumer_name(token_key, consumer_name)
93+ return rc.ACCEPTED
94+
95+ @operation(idempotent=True)
96+ def list_authorisation_tokens(self, request):
97+ """List authorisation tokens available to the currently logged-in user.
98+
99+ :return: list of dictionaries representing each key's name and token.
100+ """
101+ profile = request.user.userprofile
102+ tokens = _format_tokens(profile.get_authorisation_tokens())
103+ return HttpResponse(
104+ json.dumps(tokens, indent=4),
105+ content_type='application/json; charset=utf-8',
106+ status=int(http.client.OK))
107+
108 @classmethod
109 def resource_uri(cls, *args, **kwargs):
110 return ('account_handler', [])
111
112=== modified file 'src/maasserver/api/tests/test_api.py'
113--- src/maasserver/api/tests/test_api.py 2016-09-23 19:21:31 +0000
114+++ src/maasserver/api/tests/test_api.py 2016-10-09 22:36:13 +0000
115@@ -181,7 +181,8 @@
116
117 def test_create_authorisation_token(self):
118 # The api operation create_authorisation_token returns a json dict
119- # with the consumer_key, the token_key and the token_secret in it.
120+ # with the consumer_key, the token_key, the token_secret and the
121+ # consumer_name in it.
122 response = self.client.post(
123 reverse('account_handler'), {'op': 'create_authorisation_token'})
124 self.assertEqual(http.client.OK, response.status_code)
125@@ -189,11 +190,27 @@
126 'application/json; charset=utf-8', response["content-type"])
127 parsed_result = json_load_bytes(response.content)
128 self.assertEqual(
129- ['consumer_key', 'token_key', 'token_secret'],
130+ ['consumer_key', 'name', 'token_key', 'token_secret'],
131 sorted(parsed_result))
132 self.assertIsInstance(parsed_result['consumer_key'], str)
133 self.assertIsInstance(parsed_result['token_key'], str)
134 self.assertIsInstance(parsed_result['token_secret'], str)
135+ self.assertIsInstance(parsed_result['name'], str)
136+
137+ def test_create_authorisation_token_with_token_name(self):
138+ # The api operation create_authorisation_token can also accept
139+ # a new name for the generated token.
140+ token_name = 'Test_Token'
141+ response = self.client.post(
142+ reverse('account_handler'), {
143+ 'op': 'create_authorisation_token',
144+ 'name': token_name
145+ })
146+ self.assertEqual(http.client.OK, response.status_code)
147+ self.assertEqual(
148+ 'application/json; charset=utf-8', response["content-type"])
149+ parsed_result = json_load_bytes(response.content)
150+ self.assertEqual(parsed_result['name'], token_name)
151
152 def test_delete_authorisation_token_not_found(self):
153 # If the provided token_key does not exist (for the currently
154@@ -209,9 +226,131 @@
155 # delete_authorisation_token. It it is not present in the request's
156 # parameters, the api returns a 'Bad Request' (400) error.
157 response = self.client.post(
158- reverse('account_handler'), {'op': 'delete_authorisation_token'})
159-
160- self.assertEqual(http.client.BAD_REQUEST, response.status_code)
161+ reverse('account_handler'), {
162+ 'op': 'delete_authorisation_token'
163+ })
164+
165+ self.assertEqual(http.client.BAD_REQUEST, response.status_code)
166+
167+ def test_update_authorisation_token(self):
168+ token_name_orig = 'Test_Token'
169+ token_name_updated = 'Test_Token update'
170+ response_creation = self.client.post(
171+ reverse('account_handler'), {
172+ 'op': 'create_authorisation_token',
173+ 'name': token_name_orig
174+ })
175+ parsed_creation_response = json_load_bytes(response_creation.content)
176+ created_token = ":".join([
177+ parsed_creation_response['consumer_key'],
178+ parsed_creation_response['token_key'],
179+ parsed_creation_response['token_secret']
180+ ])
181+ self.client.post(
182+ reverse('account_handler'), {
183+ 'op': 'update_token_name', 'token': created_token,
184+ 'name': token_name_updated
185+ })
186+ response_list = self.client.get(
187+ reverse('account_handler'), {
188+ 'op': 'list_authorisation_tokens'
189+ })
190+ parsed_list_response = json_load_bytes(response_list.content)
191+ for token in parsed_list_response:
192+ if token['token'] == created_token:
193+ self.assertEqual(token['name'], token_name_updated)
194+
195+ def test_update_authorisation_token_with_token_key(self):
196+ # We use only "token_key" portion of the authorisation token
197+ # to update the token name.
198+ token_name_orig = 'Test_Token'
199+ token_name_updated = 'Test_Token update'
200+ response_creation = self.client.post(
201+ reverse('account_handler'), {
202+ 'op': 'create_authorisation_token',
203+ 'name': token_name_orig
204+ })
205+ parsed_creation_response = json_load_bytes(response_creation.content)
206+ created_token = ":".join([
207+ parsed_creation_response['consumer_key'],
208+ parsed_creation_response['token_key'],
209+ parsed_creation_response['token_secret']
210+ ])
211+ self.client.post(
212+ reverse('account_handler'), {
213+ 'op': 'update_token_name',
214+ 'token': parsed_creation_response['token_key'],
215+ 'name': token_name_updated
216+ })
217+ response_list = self.client.get(
218+ reverse('account_handler'), {
219+ 'op': 'list_authorisation_tokens'
220+ })
221+ parsed_list_response = json_load_bytes(response_list.content)
222+ for token in parsed_list_response:
223+ if token['token'] == created_token:
224+ self.assertEqual(token['name'], token_name_updated)
225+
226+ def test_update_authorisation_token_name_not_found(self):
227+ # If the provided token_key does not exist (for the currently
228+ # logged-in user), the api returns a 'Not Found' (404) error.
229+ response = self.client.post(
230+ reverse('account_handler'), {
231+ 'op': 'update_token_name', 'token': 'no-such-token',
232+ 'name': 'test_name'
233+ })
234+
235+ self.assertEqual(http.client.NOT_FOUND, response.status_code)
236+
237+ def test_update_authorisation_token_name_bad_request_no_token(self):
238+ # `token` and `name` are mandatory parameters when calling
239+ # update_token_name. If it is not present in the request's
240+ # parameters, the api returns a 'Bad Request' (400) error.
241+ response = self.client.post(
242+ reverse('account_handler'), {
243+ 'op': 'update_token_name'
244+ })
245+
246+ self.assertEqual(http.client.BAD_REQUEST, response.status_code)
247+
248+ def test_list_tokens(self):
249+ token1_name = "Test Token 1"
250+ response_creation = self.client.post(
251+ reverse('account_handler'), {
252+ 'op': 'create_authorisation_token',
253+ 'name': token1_name
254+ })
255+ parsed_creation_response = json_load_bytes(response_creation.content)
256+ response = self.client.get(
257+ reverse('account_handler'), {
258+ 'op': 'list_authorisation_tokens'
259+ })
260+ parsed_list_response = json_load_bytes(response.content)
261+ self.assertEqual(len(json_load_bytes(response.content)), 2)
262+ for token in parsed_list_response:
263+ if token['name'] == token1_name:
264+ token_fields = token['token'].split(":")
265+ self.assertEqual(
266+ token_fields[0],
267+ parsed_creation_response['consumer_key'])
268+ self.assertEqual(
269+ token_fields[1],
270+ parsed_creation_response['token_key'])
271+ self.assertEqual(
272+ token_fields[2],
273+ parsed_creation_response['token_secret'])
274+
275+ def test_list_tokens_format(self):
276+ self.client.post(
277+ reverse('account_handler'), {'op': 'create_authorisation_token'})
278+ response = self.client.get(
279+ reverse('account_handler'), {'op': 'list_authorisation_tokens'})
280+ parsed_list_response = json_load_bytes(response.content)
281+ self.assertIsInstance(parsed_list_response, list)
282+ for token in parsed_list_response:
283+ self.assertEqual(['name', 'token'], sorted(token))
284+ self.assertIsInstance(token['name'], str)
285+ self.assertIsInstance(token['token'], str)
286
287
288 class TestSSHKeyHandlers(APITestCase.ForUser):
289
290=== modified file 'src/maasserver/management/commands/apikey.py'
291--- src/maasserver/management/commands/apikey.py 2016-06-22 17:03:02 +0000
292+++ src/maasserver/management/commands/apikey.py 2016-10-09 22:36:13 +0000
293@@ -36,17 +36,34 @@
294 parser.add_argument(
295 '--delete', default=None,
296 help="Delete the supplied api key.")
297+ parser.add_argument(
298+ '--update', default=None,
299+ help="Update the supplied api key name")
300+ parser.add_argument(
301+ '--name', default=None, dest="api_key_name",
302+ help="Name of the token. This argument should be passed to "
303+ "--update or --generate options")
304+ parser.add_argument(
305+ '--with-names ', dest="with_names", action="store_true",
306+ help="Display tokens with their names.")
307
308 def _print_token(self, token):
309- """Write `token` to stdout in the standard format."""
310- self.stdout.write(convert_tuple_to_string(get_creds_tuple(token)))
311+ """Write `token` to stdout in the standard format (with names if
312+ --with-names option is enabled)"""
313+ if self.display_names:
314+ self.stdout.write("%s %s" % (
315+ convert_tuple_to_string(get_creds_tuple(token)),
316+ token.consumer.name
317+ ))
318+ else:
319+ self.stdout.write(convert_tuple_to_string(get_creds_tuple(token)))
320 # In Django 1.5+, self.stdout.write() adds a newline character at
321 # the end of the message.
322 if django.VERSION < (1, 5):
323 self.stdout.write('\n')
324
325- def _generate_token(self, user):
326- _, token = user.userprofile.create_authorisation_token()
327+ def _generate_token(self, user, consumer_name=None):
328+ _, token = user.userprofile.create_authorisation_token(consumer_name)
329 self._print_token(token)
330
331 def _delete_token(self, user, key_to_delete):
332@@ -61,6 +78,17 @@
333 except Http404:
334 raise CommandError("No matching api key found.")
335
336+ def _update_token(self, user, key_to_update, consumer_name):
337+ try:
338+ creds_tuple = convert_string_to_tuple(key_to_update)
339+ except ValueError as e:
340+ raise CommandError(e)
341+ _, token_key, _ = creds_tuple
342+ try:
343+ user.userprofile.modify_consumer_name(token_key, consumer_name)
344+ except Http404:
345+ raise CommandError("No matching api key found.")
346+
347 def handle(self, *args, **options):
348 username = options.get('username', None)
349 if username is None:
350@@ -68,16 +96,30 @@
351
352 generate = options.get('generate')
353 key_to_delete = options.get('delete', None)
354+ key_to_update = options.get('update', None)
355+ consumer_name = options.get('api_key_name', None)
356+ self.display_names = options.get('with_names', None)
357+
358 if generate and key_to_delete is not None:
359 raise CommandError("Specify one of --generate or --delete.")
360
361+ if generate and key_to_update is not None:
362+ raise CommandError("Specify one of --generate or --update.")
363+
364+ if key_to_delete and key_to_update is not None:
365+ raise CommandError("Specify one of --delete or --update.")
366+
367+ if key_to_update is not None and consumer_name is None:
368+ raise CommandError("Should specify new name for the updated key .")
369+
370 user = get_one(User.objects.filter(username=username))
371+
372 if user is None:
373 raise CommandError("User does not exist.")
374
375 if generate:
376 # Generate a new api key.
377- self._generate_token(user)
378+ self._generate_token(user, consumer_name)
379 return
380
381 elif key_to_delete is not None:
382@@ -85,6 +127,11 @@
383 self._delete_token(user, key_to_delete)
384 return
385
386+ elif key_to_update is not None:
387+ # Update an existing key name.
388+ self._update_token(user, key_to_update, consumer_name)
389+ return
390+
391 else:
392 # No mutating action requested, so just print existing keys.
393 tokens = user.userprofile.get_authorisation_tokens()
394
395=== modified file 'src/maasserver/models/user.py'
396--- src/maasserver/models/user.py 2015-12-02 20:43:30 +0000
397+++ src/maasserver/models/user.py 2016-10-09 22:36:13 +0000
398@@ -29,17 +29,21 @@
399 GENERIC_CONSUMER = 'MAAS consumer'
400
401
402-def create_auth_token(user):
403+def create_auth_token(user, consumer_name=None):
404 """Create new Token and Consumer (OAuth authorisation) for `user`.
405
406 :param user: The user to create a token for.
407 :type user: User
408+ :param consumer_name: Name of the consumer to be assigned to the newly
409+ generated token.
410 :return: The created Token.
411 :rtype: piston.models.Token
412
413 """
414+ if consumer_name is None:
415+ consumer_name = GENERIC_CONSUMER
416 consumer = Consumer.objects.create(
417- user=user, name=GENERIC_CONSUMER, status='accepted')
418+ user=user, name=consumer_name, status='accepted')
419 consumer.generate_random_codes()
420 # This is a 'generic' consumer aimed to service many clients, hence
421 # we don't authenticate the consumer with key/secret key.
422
423=== modified file 'src/maasserver/models/userprofile.py'
424--- src/maasserver/models/userprofile.py 2016-09-22 02:53:33 +0000
425+++ src/maasserver/models/userprofile.py 2016-10-09 22:36:13 +0000
426@@ -92,7 +92,7 @@
427
428 return get_auth_tokens(self.user)
429
430- def create_authorisation_token(self):
431+ def create_authorisation_token(self, consumer_name=None):
432 """Create a new Token and its related Consumer (OAuth authorisation).
433
434 :return: A tuple containing the Consumer and the Token that were
435@@ -103,7 +103,7 @@
436 # Avoid circular imports.
437 from maasserver.models.user import create_auth_token
438
439- token = create_auth_token(self.user)
440+ token = create_auth_token(self.user, consumer_name)
441 return token.consumer, token
442
443 def delete_authorisation_token(self, token_key):
444@@ -119,5 +119,20 @@
445 token.consumer.delete()
446 token.delete()
447
448+ def modify_consumer_name(self, token_key, consumer_name):
449+ """Modify consumer name of an existing token key.
450+
451+ :param token_key: The key of the token to be deleted.
452+ :type token_key: string
453+ :param consumer_name: Name of the token consumer.
454+ :type consumer_name: string
455+ :raises: `django.http.Http404`
456+ """
457+ token = get_object_or_404(
458+ Token, user=self.user, token_type=Token.ACCESS, key=token_key)
459+ token.consumer.name = consumer_name
460+ token.consumer.save()
461+ token.save()
462+
463 def __str__(self):
464 return self.user.username
465
466=== modified file 'src/maasserver/tests/test_commands.py'
467--- src/maasserver/tests/test_commands.py 2016-09-23 18:03:06 +0000
468+++ src/maasserver/tests/test_commands.py 2016-10-09 22:36:13 +0000
469@@ -427,3 +427,59 @@
470 self, 'apikey', username=user.username, delete=token_string)
471 self.assertIn(
472 "No matching api key found", error_text)
473+
474+ def test_success_modify_apikey_name(self):
475+ stderr = StringIO()
476+ stdout = StringIO()
477+ fake_api_key_name = "Test Key Name"
478+ user = factory.make_User()
479+ existing_token = get_one(
480+ user.userprofile.get_authorisation_tokens())
481+ token_string = convert_tuple_to_string(
482+ get_creds_tuple(existing_token))
483+ call_command(
484+ 'apikey', username=user.username, update=token_string,
485+ api_key_name=fake_api_key_name, stderr=stderr, stdout=stdout)
486+ self.assertThat(stderr, IsEmpty)
487+
488+ def test_api_key_rejects_update_of_nonexistent_key(self):
489+ stderr = StringIO()
490+ user = factory.make_User()
491+ fake_api_key_name = "Test Key Name"
492+ existing_token = get_one(
493+ user.userprofile.get_authorisation_tokens())
494+ token_string = convert_tuple_to_string(
495+ get_creds_tuple(existing_token))
496+ call_command(
497+ 'apikey', username=user.username, delete=token_string,
498+ stderr=stderr)
499+ self.assertThat(stderr, IsEmpty)
500+
501+ # Try to update the deleted token.
502+ error_text = assertCommandErrors(
503+ self, 'apikey', username=user.username, update=token_string,
504+ api_key_name=fake_api_key_name)
505+ self.assertIn(
506+ "No matching api key found", error_text)
507+
508+ def test_api_key_rejects_update_without_key_name(self):
509+ user = factory.make_User()
510+ existing_token = get_one(
511+ user.userprofile.get_authorisation_tokens())
512+ token_string = convert_tuple_to_string(
513+ get_creds_tuple(existing_token))
514+ error_text = assertCommandErrors(
515+ self, 'apikey', username=user.username, update=token_string)
516+ self.assertIn("Should specify new name", error_text)
517+
518+ def test_api_key_update_and_generate_mutually_exclusive_options(self):
519+ user = factory.make_User()
520+ fake_api_key_name = "Test Key Name"
521+ existing_token = get_one(
522+ user.userprofile.get_authorisation_tokens())
523+ token_string = convert_tuple_to_string(
524+ get_creds_tuple(existing_token))
525+ error_text = assertCommandErrors(
526+ self, 'apikey', username=user.username, generate=True,
527+ api_key_name=fake_api_key_name, update=token_string)
528+ self.assertIn("Specify one of --generate or --update.", error_text)