From 72565cc49c469e3b949fa0e3a433545b0ec46cc5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 9 Aug 2025 09:56:00 -0500 Subject: [PATCH] feat: add tools to manage user API tokens --- src/wuttaweb/templates/users/view.mako | 126 ++++++++++++++++++++++++ src/wuttaweb/views/users.py | 128 +++++++++++++++++++++++++ tests/views/test_users.py | 118 +++++++++++++++++++++++ 3 files changed, 372 insertions(+) create mode 100644 src/wuttaweb/templates/users/view.mako diff --git a/src/wuttaweb/templates/users/view.mako b/src/wuttaweb/templates/users/view.mako new file mode 100644 index 0000000..bc5681f --- /dev/null +++ b/src/wuttaweb/templates/users/view.mako @@ -0,0 +1,126 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="page_content()"> + ${parent.page_content()} + + % if master.has_perm('manage_api_tokens'): + + + + % endif + + +<%def name="render_form_tag()"> + % if master.has_perm('manage_api_tokens'): + ${form.render_vue_tag(**{'@new-token': 'newTokenInit', '@delete-token': 'deleteTokenInit'})} + % else: + ${form.render_vue_tag()} + % endif + + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + + diff --git a/src/wuttaweb/views/users.py b/src/wuttaweb/views/users.py index 760152d..aa5841e 100644 --- a/src/wuttaweb/views/users.py +++ b/src/wuttaweb/views/users.py @@ -48,6 +48,10 @@ class UserView(MasterView): """ model_class = User + labels = { + 'api_tokens': "API Tokens", + } + grid_columns = [ 'username', 'person', @@ -66,6 +70,7 @@ class UserView(MasterView): 'active', 'prevent_edit', 'roles', + 'api_tokens', ] def get_query(self, session=None): @@ -151,6 +156,12 @@ class UserView(MasterView): if not self.creating: f.set_default('roles', [role.uuid.hex for role in user.roles]) + # api_tokens + if self.viewing and self.has_perm('manage_api_tokens'): + f.set_grid('api_tokens', self.make_api_tokens_grid(user)) + else: + f.remove('api_tokens') + def unique_username(self, node, value): """ """ model = self.app.model @@ -251,6 +262,93 @@ class UserView(MasterView): role = session.get(model.Role, uuid) user.roles.remove(role) + def make_api_tokens_grid(self, user): + """ + Make and return the grid for the API Tokens field. + + This is only shown when current user has permission to manage + API tokens for other users. + + :rtype: :class:`~wuttaweb.grids.base.Grid` + """ + model = self.app.model + route_prefix = self.get_route_prefix() + + grid = self.make_grid(key=f'{route_prefix}.view.api_tokens', + data=[self.normalize_api_token(t) for t in user.api_tokens], + columns=[ + 'description', + 'created', + ], + sortable=True, + sort_on_backend=False, + sort_defaults=[('created', 'desc')]) + + if self.has_perm('manage_api_tokens'): + + # create token + button = self.make_button("New", primary=True, icon_left='plus', **{'@click': "$emit('new-token')"}) + grid.add_tool(button, key='create') + + # delete token + grid.add_action('delete', url='#', icon='trash', link_class='has-text-danger', click_handler="$emit('delete-token', props.row)") + + return grid + + def normalize_api_token(self, token): + """ """ + return { + 'uuid': token.uuid.hex, + 'description': token.description, + 'created': self.app.render_datetime(token.created), + } + + def add_api_token(self): + """ + AJAX view for adding a new user API token. + + This calls + :meth:`wuttjamaican:wuttjamaican.auth.AuthHandler.add_api_token()` + for the creation logic. + """ + session = self.Session() + auth = self.app.get_auth_handler() + user = self.get_instance() + data = self.request.json_body + + token = auth.add_api_token(user, data['description']) + session.flush() + session.refresh(token) + + result = self.normalize_api_token(token) + result['token_string'] = token.token_string + result['_action_url_delete'] = '#' + return result + + def delete_api_token(self): + """ + AJAX view for deleting a user API token. + + This calls + :meth:`wuttjamaican:wuttjamaican.auth.AuthHandler.delete_api_token()` + for the deletion logic. + """ + model = self.app.model + session = self.Session() + auth = self.app.get_auth_handler() + user = self.get_instance() + data = self.request.json_body + + token = session.get(model.UserAPIToken, data['uuid']) + if not token: + return {'error': "API token not found"} + + if token.user is not user: + return {'error': "API token not found"} + + auth.delete_api_token(token) + return {} + @classmethod def defaults(cls, config): """ """ @@ -260,8 +358,38 @@ class UserView(MasterView): app = wutta_config.get_app() cls.model_class = app.model.User + cls._user_defaults(config) cls._defaults(config) + @classmethod + def _user_defaults(cls, config): + """ + Provide extra default configuration for the User master view. + """ + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + instance_url_prefix = cls.get_instance_url_prefix() + model_title = cls.get_model_title() + + # manage API tokens + config.add_wutta_permission(permission_prefix, + f'{permission_prefix}.manage_api_tokens', + f"Manage API tokens for any {model_title}") + config.add_route(f'{route_prefix}.add_api_token', + f'{instance_url_prefix}/add-api-token', + request_method='POST') + config.add_view(cls, attr='add_api_token', + route_name=f'{route_prefix}.add_api_token', + permission=f'{permission_prefix}.manage_api_tokens', + renderer='json') + config.add_route(f'{route_prefix}.delete_api_token', + f'{instance_url_prefix}/delete-api-token', + request_method='POST') + config.add_view(cls, attr='delete_api_token', + route_name=f'{route_prefix}.delete_api_token', + permission=f'{permission_prefix}.manage_api_tokens', + renderer='json') + def defaults(config, **kwargs): base = globals() diff --git a/tests/views/test_users.py b/tests/views/test_users.py index 96f4404..dbd369e 100644 --- a/tests/views/test_users.py +++ b/tests/views/test_users.py @@ -6,6 +6,7 @@ from sqlalchemy import orm import colander +from wuttaweb.grids import Grid from wuttaweb.views import users as mod from wuttaweb.testing import WebTestCase @@ -122,6 +123,18 @@ class TestUserView(WebTestCase): view.configure_form(form) self.assertNotIn('password', form) + # api tokens grid shown only if current user has perm + with patch.object(view, 'viewing', new=True): + form = view.make_form(model_instance=barney) + self.assertIn('api_tokens', form) + view.configure_form(form) + self.assertNotIn('api_tokens', form) + with patch.object(self.request, 'is_root', new=True): + form = view.make_form(model_instance=barney) + self.assertIn('api_tokens', form) + view.configure_form(form) + self.assertIn('api_tokens', form) + def test_unique_username(self): model = self.app.model view = self.make_view() @@ -317,3 +330,108 @@ class TestUserView(WebTestCase): user = view.objectify(form) self.assertIs(user, barney) self.assertEqual(len(user.roles), 2) + + def test_normalize_api_token(self): + model = self.app.model + auth = self.app.get_auth_handler() + view = self.make_view() + + user = model.User(username='foo') + self.session.add(user) + token = auth.add_api_token(user, 'test token') + self.session.commit() + + normal = view.normalize_api_token(token) + self.assertIn('uuid', normal) + self.assertEqual(normal['uuid'], token.uuid.hex) + self.assertIn('description', normal) + self.assertEqual(normal['description'], 'test token') + self.assertIn('created', normal) + + def test_make_api_tokens_grid(self): + model = self.app.model + auth = self.app.get_auth_handler() + view = self.make_view() + + user = model.User(username='foo') + self.session.add(user) + token1 = auth.add_api_token(user, 'test1') + token2 = auth.add_api_token(user, 'test2') + self.session.commit() + + # grid should have 2 records but no tools/actions + grid = view.make_api_tokens_grid(user) + self.assertIsInstance(grid, Grid) + self.assertEqual(len(grid.data), 2) + self.assertEqual(len(grid.tools), 0) + self.assertEqual(len(grid.actions), 0) + + # create + delete allowed + with patch.object(self.request, 'is_root', new=True): + grid = view.make_api_tokens_grid(user) + self.assertEqual(len(grid.tools), 1) + self.assertIn('create', grid.tools) + self.assertEqual(len(grid.actions), 1) + self.assertEqual(grid.actions[0].key, 'delete') + + def test_add_api_token(self): + model = self.app.model + view = self.make_view() + + user = model.User(username='foo') + self.session.add(user) + self.session.commit() + self.session.refresh(user) + self.assertEqual(len(user.api_tokens), 0) + + with patch.object(view, 'Session', return_value=self.session): + with patch.object(self.request, 'matchdict', new={'uuid': user.uuid}): + with patch.object(self.request, 'json_body', create=True, + new={'description': 'testing'}): + result = view.add_api_token() + self.assertEqual(len(user.api_tokens), 1) + token = user.api_tokens[0] + self.assertEqual(result['token_string'], token.token_string) + self.assertEqual(result['description'], 'testing') + + def test_delete_api_token(self): + model = self.app.model + auth = self.app.get_auth_handler() + view = self.make_view() + + user = model.User(username='foo') + self.session.add(user) + token1 = auth.add_api_token(user, 'test1') + token2 = auth.add_api_token(user, 'test2') + self.session.commit() + self.session.refresh(user) + self.assertEqual(len(user.api_tokens), 2) + + with patch.object(view, 'Session', return_value=self.session): + with patch.object(self.request, 'matchdict', new={'uuid': user.uuid}): + + # normal behavior + with patch.object(self.request, 'json_body', create=True, + new={'uuid': token1.uuid.hex}): + result = view.delete_api_token() + self.assertEqual(result, {}) + self.session.refresh(user) + self.assertEqual(len(user.api_tokens), 1) + token = user.api_tokens[0] + self.assertIs(token, token2) + + # token for wrong user + user2 = model.User(username='bar') + self.session.add(user2) + token3 = auth.add_api_token(user2, 'test3') + self.session.commit() + with patch.object(self.request, 'json_body', create=True, + new={'uuid': token3.uuid.hex}): + result = view.delete_api_token() + self.assertEqual(result, {'error': "API token not found"}) + + # token not found + with patch.object(self.request, 'json_body', create=True, + new={'uuid': self.app.make_true_uuid().hex}): + result = view.delete_api_token() + self.assertEqual(result, {'error': "API token not found"})