From 036a863b00fe079e13bb1640267078ec47e6f9e5 Mon Sep 17 00:00:00 2001 From: Lars Wirzenius Date: Wed, 1 Aug 2018 13:48:44 +0300 Subject: Add: management API --- qvisqve/__init__.py | 1 + qvisqve/api.py | 5 +- qvisqve/entity_manager.py | 3 + qvisqve/entity_manager_tests.py | 11 +++ qvisqve/file_store.py | 5 ++ qvisqve/file_store_tests.py | 11 +++ qvisqve/management_router.py | 134 +++++++++++++++++++++++++++++++++ without-tests | 1 + yarns/400-manage.yarn | 162 ++++++++++++++++++++++++++++++++++++++++ yarns/900-implements.yarn | 5 ++ yarns/900-local.yarn | 12 ++- 11 files changed, 342 insertions(+), 8 deletions(-) create mode 100644 qvisqve/management_router.py create mode 100644 yarns/400-manage.yarn diff --git a/qvisqve/__init__.py b/qvisqve/__init__.py index ec7a48c..0627c7b 100644 --- a/qvisqve/__init__.py +++ b/qvisqve/__init__.py @@ -45,6 +45,7 @@ from .token import TokenGenerator from .router import Router from .version_router import VersionRouter +from .management_router import ManagementRouter from .login_router import LoginRouter from .auth_router import AuthRouter from .token_router import TokenRouter diff --git a/qvisqve/api.py b/qvisqve/api.py index 0866ae3..b63bdb6 100644 --- a/qvisqve/api.py +++ b/qvisqve/api.py @@ -28,8 +28,12 @@ class API: qvisqve.log.log('info', msg_text='find_missing_route', path=path) if self._routes is None: + storedir = self._config['store'] + baseurl = self._config['token-issuer'] + routers = [ qvisqve.VersionRouter(), + qvisqve.ManagementRouter(storedir, baseurl), qvisqve.TokenRouter( self._create_token_generator(), self._get_clients()), qvisqve.LoginRouter(), @@ -56,7 +60,6 @@ class API: return tg def _create_resource_store(self): - qvisqve.log.log('debug', msg_text='_c_r_s 1', c=self._config) if self._rs is None: self._rs = qvisqve.FileStore(self._config['store']) return self._rs diff --git a/qvisqve/entity_manager.py b/qvisqve/entity_manager.py index b39d7e6..a4a1705 100644 --- a/qvisqve/entity_manager.py +++ b/qvisqve/entity_manager.py @@ -26,6 +26,9 @@ class EntityManager: def get(self, entity_id): return self._store.get(self._type, entity_id) + def delete(self, entity_id): + return self._store.delete(self._type, entity_id) + def create(self, entity_id, entity): self._store.create(self._type, entity_id, entity) diff --git a/qvisqve/entity_manager_tests.py b/qvisqve/entity_manager_tests.py index 4ad9bb1..1ca2fad 100644 --- a/qvisqve/entity_manager_tests.py +++ b/qvisqve/entity_manager_tests.py @@ -45,6 +45,17 @@ class EntityManagerTests(unittest.TestCase): self.assertEqual(self.em.list(), [foo_id]) self.assertEqual(self.em.get(foo_id), entity) + def test_deletes_an_entity(self): + entity = { + 'foo': 'foo is cool', + } + foo_id = 'foo is my entity' + self.em.create(foo_id, entity) + self.em.delete(foo_id) + self.assertEqual(self.em.list(), []) + with self.assertRaises(qvisqve.ResourceDoesNotExist): + self.em.get('does-not-exist') + class ApplicationManagerTests(unittest.TestCase): diff --git a/qvisqve/file_store.py b/qvisqve/file_store.py index 3fd5186..b22f2e7 100644 --- a/qvisqve/file_store.py +++ b/qvisqve/file_store.py @@ -56,6 +56,11 @@ class FileStore: with open(filename, 'w') as f: yaml.safe_dump(resource, stream=f) + def delete(self, resource_type, resource_id): + filename = self._filename(resource_type, resource_id) + if os.path.exists(filename): + os.remove(filename) + class ResourceStoreError(Exception): diff --git a/qvisqve/file_store_tests.py b/qvisqve/file_store_tests.py index 6d27afc..9260235 100644 --- a/qvisqve/file_store_tests.py +++ b/qvisqve/file_store_tests.py @@ -95,3 +95,14 @@ class FileStoreTests(unittest.TestCase): self.assertEqual(client, self.fs.get('client', client['id'])) self.assertEqual(user, self.fs.get('user', user['id'])) + + def test_deletes_client(self): + client = { + 'id': 'test-client', + } + self.fs.create('client', client['id'], client) + self.fs.delete('client', client['id']) + self.assertEqual(self.fs.list('client'), []) + + with self.assertRaises(qvisqve.ResourceDoesNotExist): + self.fs.get('client', client['id']) diff --git a/qvisqve/management_router.py b/qvisqve/management_router.py new file mode 100644 index 0000000..7ae04b0 --- /dev/null +++ b/qvisqve/management_router.py @@ -0,0 +1,134 @@ +# Copyright (C) 2018 Lars Wirzenius +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +import os +import urllib.parse + + +import bottle + + +import qvisqve +import qvisqve_secrets + + +class ManagementRouter(qvisqve.Router): + + def __init__(self, storedir, baseurl): + super().__init__() + rs = qvisqve.FileStore(storedir) + self._clients = qvisqve.ClientManager(rs) + self._users = qvisqve.UserManager(rs) + self._apps = qvisqve.ApplicationManager(rs) + self._baseurl = baseurl + + def get_routes(self): + clients = ManagementEndpoint(self._baseurl, '/clients', self._clients) + users = ManagementEndpoint(self._baseurl, '/users', self._users) + apps = ManagementEndpoint(self._baseurl, '/applications', self._apps) + + return clients.get_routes() + users.get_routes() + apps.get_routes() + + +class ManagementEndpoint: + + def __init__(self, baseurl, path, entities): + self._baseurl = baseurl + self._path = path + self._entities = entities + + def get_routes(self): + return [ + { + 'method': 'POST', + 'path': self._path, + 'callback': self._create, + }, + { + 'method': 'GET', + 'path': self._path, + 'callback': self._list, + }, + { + 'method': 'GET', + 'path': '{}/'.format(self._path), + 'callback': self._show, + }, + { + 'method': 'PUT', + 'path': '{}//secret'.format(self._path), + 'callback': self._set_secret, + }, + { + 'method': 'DELETE', + 'path': '{}/'.format(self._path), + 'callback': self._delete, + }, + ] + + def _create(self, content_type, body, **kwargs): + qvisqve.log.log('info', msg_text='Creating', path=self._path) + + entity_id = body.get('id') + if not entity_id: + return qvisqve.bad_request_response( + 'No {}'.format(self._entity_id_name)) + + self._entities.create(entity_id, body) + + entity = self._entities.get(entity_id) + location = '{}{}/{}'.format(self._baseurl, self._path, entity_id) + return qvisqve.created_response(entity, location) + + def _list(self, content_type, body, **kwargs): + qvisqve.log.log('info', msg_text='Listing', path=self._path) + # FIXME + return qvisqve.ok_response({"resources": []}) + + def _show(self, content_type, body, **kwargs): + qvisqve.log.log('info', msg_text='Showing client') + + entity_id = kwargs['id'] + try: + entity = self._entities.get(entity_id) + except qvisqve.ResourceDoesNotExist as e: + return qvisqve.not_found_response() + + qvisqve.log.log('info', msg_text='Showing', entity=entity) + return qvisqve.ok_response(entity) + + def _set_secret(self, content_type, body, **kwargs): + qvisqve.log.log('info', msg_text='Setting client secret') + + entity_id = kwargs['id'] + try: + entity = self._entities.get(entity_id) + except qvisqve.ResourceDoesNotExist as e: + return qvisqve.not_found_response() + + secret = body.get('secret') + if not secret: + return qvisqve.bad_request_response('No secret') + + self._entities.set_secret(entity_id, secret) + return qvisqve.ok_response(entity) + + def _delete(self, content_type, body, **kwargs): + qvisqve.log.log('info', msg_text='Deleting', path=self._path) + + entity_id = kwargs['id'] + self._entities.delete(entity_id) + return qvisqve.ok_response('') diff --git a/without-tests b/without-tests index 1fc97eb..11a86db 100644 --- a/without-tests +++ b/without-tests @@ -7,6 +7,7 @@ qvisqve/auth_router.py qvisqve/backend.py qvisqve/log_setup.py qvisqve/login_router.py +qvisqve/management_router.py qvisqve/responses.py qvisqve/router.py qvisqve/token.py diff --git a/yarns/400-manage.yarn b/yarns/400-manage.yarn new file mode 100644 index 0000000..6d60909 --- /dev/null +++ b/yarns/400-manage.yarn @@ -0,0 +1,162 @@ +Manage clients, users, applications via API +============================================================================= + + SCENARIO manage clients, users, applications + GIVEN an RSA key pair for token signing + AND a Qvisqve configuration for "https://qvisqve.example.com" + AND Qvisqve configuration has a token lifetime of 3600 + AND a running Qvisqve instance + AND an access token for admin with scopes + ... uapi_clients_post + ... uapi_clients_get + ... uapi_clients_id_get + ... uapi_clients_id_put + ... uapi_clients_id_secret_put + ... uapi_clients_id_delete + ... uapi_users_post + ... uapi_users_get + ... uapi_users_id_get + ... uapi_users_id_put + ... uapi_users_id_secret_put + ... uapi_users_id_delete + ... uapi_applications_post + ... uapi_applications_get + ... uapi_applications_id_get + ... uapi_applications_id_put + ... uapi_applications_id_delete + +First, manage clients. + + WHEN client requests GET /clients using token + THEN HTTP status code is 200 OK + AND Content-Type is application/json + AND JSON body matches + ... { + ... "resources": [] + ... } + + WHEN client requests POST /clients with token and body + ... { + ... "id": "james" + ... } + THEN HTTP status code is 201 Created + AND Location is https://qvisqve.example.com/clients/james + + WHEN client requests PUT /clients/james/secret with token and body + ... { "secret": "hunter2" } + THEN HTTP status code is 200 OK + + WHEN client requests GET /clients using token + THEN HTTP status code is 200 OK + AND JSON body matches + ... { + ... "resources": ["james"] + ... } + + WHEN client requests GET /clients/james using token + THEN HTTP status code is 200 OK + AND JSON body matches + ... { + ... "id": "james" + ... } + + WHEN client requests DELETE /clients/james with token + THEN HTTP status code is 200 OK + WHEN client requests GET /clients/james using token + THEN HTTP status code is 404 Not Found + WHEN client requests GET /clients using token + THEN HTTP status code is 200 OK + AND JSON body matches + ... { + ... "resources": [] + ... } + +Then, manage users. + + WHEN client requests GET /users using token + THEN HTTP status code is 200 OK + AND Content-Type is application/json + AND JSON body matches + ... { + ... "resources": [] + ... } + + WHEN client requests POST /users with token and body + ... { + ... "id": "sherlock" + ... } + THEN HTTP status code is 201 Created + AND Location is https://qvisqve.example.com/users/sherlock + + WHEN client requests PUT /users/sherlock/secret with token and body + ... { "secret": "hunter2" } + THEN HTTP status code is 200 OK + + WHEN client requests GET /users using token + THEN HTTP status code is 200 OK + AND JSON body matches + ... { + ... "resources": ["sherlock"] + ... } + + WHEN client requests GET /users/sherlock using token + THEN HTTP status code is 200 OK + AND JSON body matches + ... { + ... "id": "sherlock" + ... } + + WHEN client requests DELETE /users/sherlock with token + THEN HTTP status code is 200 OK + WHEN client requests GET /users/sherlock using token + THEN HTTP status code is 404 Not Found + WHEN client requests GET /users using token + THEN HTTP status code is 200 OK + AND JSON body matches + ... { + ... "resources": [] + ... } + +Then, manage applications. + + WHEN client requests GET /applications using token + THEN HTTP status code is 200 OK + AND Content-Type is application/json + AND JSON body matches + ... { + ... "resources": [] + ... } + + WHEN client requests POST /applications with token and body + ... { + ... "id": "MI6", + ... "callbacks": ["https://mi6.example.com/callback"] + ... } + THEN HTTP status code is 201 Created + AND Location is https://qvisqve.example.com/applications/MI6 + + WHEN client requests GET /applications using token + THEN HTTP status code is 200 OK + AND JSON body matches + ... { + ... "resources": ["MI6"] + ... } + + WHEN client requests GET /applications/MI6 using token + THEN HTTP status code is 200 OK + AND JSON body matches + ... { + ... "id": "MI6", + ... "callbacks": ["https://mi6.example.com/callback"] + ... } + + WHEN client requests DELETE /applications/MI6 with token + THEN HTTP status code is 200 OK + WHEN client requests GET /applications/MI6 using token + THEN HTTP status code is 404 Not Found + WHEN client requests GET /applications using token + THEN HTTP status code is 200 OK + AND JSON body matches + ... { + ... "resources": [] + ... } diff --git a/yarns/900-implements.yarn b/yarns/900-implements.yarn index 7eea6d8..25a7e11 100644 --- a/yarns/900-implements.yarn +++ b/yarns/900-implements.yarn @@ -183,6 +183,11 @@ This chapter shows the scenario step implementations. headers = V['headers'] assertEqual(headers['Content-Type'][:len(wanted)], wanted) + IMPLEMENTS THEN Location is (\S+) + wanted = get_next_match() + headers = V['headers'] + assertEqual(headers['Location'], wanted) + IMPLEMENTS THEN body is a correctly signed JWT token resp = json.loads(V['body']) assertIn('access_token', resp) diff --git a/yarns/900-local.yarn b/yarns/900-local.yarn index ddce8f8..8c9fd1d 100644 --- a/yarns/900-local.yarn +++ b/yarns/900-local.yarn @@ -60,13 +60,11 @@ along with this program. If not, see . IMPLEMENTS GIVEN an access token for (\S+) with scopes (.+) user = get_next_match() scopes = get_next_match() - key = open('token.key').read() - argv = [ - os.path.join(srcdir, 'create-token'), - scopes, - ] - token = cliapp.runcmd(argv, feed_stdin=key) - store_token(user, token) + key = V['privkey'] + issuer = V['iss'] + audience = V['aud'] + token = create_token(key, issuer, audience, scopes) + V['token'] = token ## Start Qvisqve -- cgit v1.2.1