From 4156c58aa914b11c6c4cc0318f096422efda2d4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erick=20Dur=C3=A1n?= Date: Fri, 27 Jan 2023 15:48:47 -0800 Subject: [PATCH 1/5] implementing service credentials route --- confidant/routes/services.py | 170 +++++++++++++++++++++++++++++++++++ confidant/schema/services.py | 56 ++++++++++++ confidant/utils/misc.py | 35 ++++++++ 3 files changed, 261 insertions(+) diff --git a/confidant/routes/services.py b/confidant/routes/services.py index c355cdb5..1d3f4893 100644 --- a/confidant/routes/services.py +++ b/confidant/routes/services.py @@ -12,6 +12,8 @@ services_response_schema, RevisionsResponse, revisions_response_schema, + ServiceCredentialsResponse, + service_credentials_response_schema, ) from confidant.services import ( credentialmanager, @@ -283,6 +285,174 @@ def get_service(id): return service_expanded_response_schema.dumps(service_response) +@blueprint.route('/v1/services//credentials', methods=['GET']) +@authnz.require_auth +def get_service_credentials(id): + ''' + Get the credentials for the service with the provided ID. + + **Example request**: + + .. sourcecode:: http + + GET /v1/services/example-development/credentials + + :param id: The service ID to get. + :type id: str + :query boolean metadata_only: If true, only fetch metadata for this + service, and do not respond with decrypted credential pairs in the + credential responses. + :query boolean blind: If true, fetch blind credentials instead. + :query int page: the page to fetch, leave unspecified for first page. + :query int limit: the number of items per page (required for pagination). + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "credentials": [ + { + "id": "abcd12345bf4f1cafe8e722d3860404", + "name": "Example Credential", + "credential_keys": ["test_key"], + "credential_pairs": { + "test_key": "test_value" + }, + "metadata": { + "example_metadata_key": "example_value" + }, + "revision": 1, + "enabled": true, + "documentation": "Example documentation", + "modified_date": "2019-12-16T23:16:11.413299+00:00", + "modified_by": "rlane@example.com", + "permissions": {} + }, + ... + ], + "blind_credentials": [], + "next_page": 2 + } + + :resheader Content-Type: application/json + :statuscode 200: Success + :statuscode 403: Client does not have permissions to get the service ID + provided. + ''' + permissions = { + 'metadata': False, + 'get': False, + 'update': False, + } + metadata_only = misc.get_boolean(request.args.get('metadata_only')) + logged_in_user = authnz.get_logged_in_user() + action = 'metadata' if metadata_only else 'get' + if action == 'metadata': + permissions['metadata'] = acl_module_check( + resource_type='service', + action='metadata', + resource_id=id, + ) + elif action == 'get': + permissions['get'] = acl_module_check( + resource_type='service', + action='get', + resource_id=id, + ) + if not permissions[action]: + msg = "{} does not have access to get service {}".format( + authnz.get_logged_in_user(), + id + ) + error_msg = {'error': msg, 'reference': id} + return jsonify(error_msg), 403 + + logger.info( + 'get_service called on id={} by user={} metadata_only={}'.format( + id, + logged_in_user, + metadata_only, + ) + ) + try: + service = Service.get(id) + if not authnz.service_in_account(service.account): + logger.warning( + 'Authz failed for service {0} (wrong account).'.format(id) + ) + msg = 'Authenticated user is not authorized.' + return jsonify({'error': msg}), 401 + except DoesNotExist: + return jsonify({}), 404 + + if (service.data_type != 'service' and + service.data_type != 'archive-service'): + return jsonify({}), 404 + + logger.debug('Authz succeeded for service {0}.'.format(id)) + + limit = request.args.get( + 'limit', + default=None, + type=int, + ) + page = request.args.get( + 'page', + default=None, + type=int + ) + blind = request.args.get( + 'blind', + default=False, + type=bool, + ) + + all_ids = service.credentials + next_page = None + if blind: + all_ids = service.blind_credentials + + if limit: + query_items, next_page = misc.get_page(all_ids, limit, page) + else: + query_items = all_ids + + try: + if blind: + credentials = credentialmanager.get_blind_credentials(query_items) + else: + credentials = credentialmanager.get_credentials(query_items) + except KeyError: + logger.exception('KeyError occurred in getting credentials') + return jsonify({'error': 'Decryption error.'}), 500 + + if authnz.user_is_user_type('user'): + permissions['update'] = acl_module_check( + resource_type='service', + action='update', + resource_id=id, + kwargs={ + 'credential_ids': query_items, + }, + ) + kwargs = { + 'next_page': next_page, + 'metadata_only': metadata_only + } + if blind: + kwargs['blind_credentials'] = credentials + else: + kwargs['credentials'] = credentials + + return service_credentials_response_schema.dumps( + ServiceCredentialsResponse.from_credentials(**kwargs) + ) + + @blueprint.route('/v1/archive/services/', methods=['GET']) @authnz.require_auth def get_archive_service_revisions(id): diff --git a/confidant/schema/services.py b/confidant/schema/services.py index cbcdcbec..10f169a4 100644 --- a/confidant/schema/services.py +++ b/confidant/schema/services.py @@ -123,6 +123,61 @@ class Meta: permissions = fields.Dict(keys=fields.Str(), values=fields.Boolean()) +@attr.s +class ServiceCredentialsResponse(object): + next_page = attr.ib() + credentials = attr.ib(default=list) + blind_credentials = attr.ib(default=list) + + @classmethod + def from_credentials( + cls, + credentials=None, + blind_credentials=None, + next_page=None, + metadata_only=True, + ): + ret = cls(next_page) + + if metadata_only: + include_sensitive = False + else: + include_sensitive = True + + if credentials: + ret.credentials = [ + CredentialResponse.from_credential( + credential, + include_credential_keys=True, + include_credential_pairs=include_sensitive, + ) + for credential in credentials + ] + if blind_credentials: + ret.blind_credentials = [ + BlindCredentialResponse.from_blind_credential( + blind_credential, + include_credential_keys=True, + include_credential_pairs=include_sensitive, + include_data_key=include_sensitive, + ) + for blind_credential in blind_credentials + ] + + return ret + + +class ServiceCredentialsResponseSchema(AutobuildSchema): + class Meta: + jit = toastedmarshmallow.Jit + + _class_to_load = ServiceResponse + + credentials = fields.List(fields.Nested(CredentialResponseSchema)) + blind_credentials = fields.List(fields.Nested(BlindCredentialResponseSchema)) + next_page = fields.Int() + + @attr.s class ServicesResponse(object): services = attr.ib() @@ -228,5 +283,6 @@ def sort_revisions(self, item): service_expanded_response_schema = ServiceExpandedResponseSchema() +service_credentials_response_schema = ServiceCredentialsResponseSchema() services_response_schema = ServicesResponseSchema() revisions_response_schema = RevisionsResponseSchema() diff --git a/confidant/utils/misc.py b/confidant/utils/misc.py index 3bf320bf..13a29247 100644 --- a/confidant/utils/misc.py +++ b/confidant/utils/misc.py @@ -3,6 +3,9 @@ from datetime import datetime +split_cache = {} + + def dict_deep_update(a, b): """ Deep merge in place of two dicts. For all keys in `b`, override matching @@ -54,3 +57,35 @@ def utcnow(): """ now = datetime.utcnow() return now.replace(tzinfo=pytz.utc) + + +def _split_items(items, limit): + if limit not in split_cache: + split_cache[limit] = {} + key = str(items) + if key not in split_cache[limit]: + items = sorted(items) + split_cache[limit][key] = [] + for i in range(0, len(items), limit): + split_cache[limit][key].append(items[i:i+limit]) + return split_cache[limit][key] + + +def get_page(items, limit, page): + # no page specified (first page) + if page is None: + page = 1 + pages = _split_items(items, limit) + total = len(pages) + + # if there is one, calculate next page + # (consistent with other methods) + next_page = None + if page < total: + next_page = page + 1 + + # validate page within range + if 1 <= page <= total: + return _split_items(items, limit)[page-1], next_page + else: + return [], None From 36563dbaf1516851f382a3b2e5687e89393ad80f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erick=20Dur=C3=A1n?= Date: Fri, 27 Jan 2023 15:54:18 -0800 Subject: [PATCH 2/5] fix lint --- confidant/schema/services.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/confidant/schema/services.py b/confidant/schema/services.py index 10f169a4..a01408ac 100644 --- a/confidant/schema/services.py +++ b/confidant/schema/services.py @@ -174,7 +174,9 @@ class Meta: _class_to_load = ServiceResponse credentials = fields.List(fields.Nested(CredentialResponseSchema)) - blind_credentials = fields.List(fields.Nested(BlindCredentialResponseSchema)) + blind_credentials = fields.List( + fields.Nested(BlindCredentialResponseSchema) + ) next_page = fields.Int() From ed0ca5c4bc1579dad75863610b38ded0b9a5ec66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erick=20Dur=C3=A1n?= Date: Fri, 27 Jan 2023 16:53:12 -0800 Subject: [PATCH 3/5] add unit tests --- .../routes/service_credentials_test.py | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 tests/unit/confidant/routes/service_credentials_test.py diff --git a/tests/unit/confidant/routes/service_credentials_test.py b/tests/unit/confidant/routes/service_credentials_test.py new file mode 100644 index 00000000..21b02956 --- /dev/null +++ b/tests/unit/confidant/routes/service_credentials_test.py @@ -0,0 +1,128 @@ +import json +import pytest + +from unittest import mock +from datetime import datetime + +from confidant.app import create_app +from confidant.models.service import Service +from confidant.models.credential import Credential + + +@pytest.fixture() +def service(mocker): + return Service( + id='something-production-iad', + data_type='service', + credentials=['1234', '5678'], + blind_credentials=set(), + enabled=True, + revision=1, + modified_by='user' + ) + + +@pytest.fixture() +def credential_list(mocker): + credentials = [ + Credential( + id='1234', + revision=1, + data_type='credential', + enabled=True, + name='Test credential', + credential_pairs='akjlaklkaj==', + data_key='slkjlksfjklsdjf==', + cipher_version=2, + metadata={}, + modified_date=datetime.now(), + modified_by='test@example.com', + documentation='', + ), + Credential( + id='5678', + revision=2, + data_type='credential', + enabled=True, + name='Test credential 2', + credential_pairs='akjlaklkaj==', + data_key='slkjlksfjklsdjf==', + cipher_version=2, + metadata={}, + modified_date=datetime.now(), + modified_by='test@example.com', + documentation='', + ), + ] + return credentials + + +def test_get_services_list(mocker, service, credential_list): + app = create_app() + + mocker.patch('confidant.settings.USE_AUTH', False) + mocker.patch( + 'confidant.routes.services.authnz.get_logged_in_user', + return_value='test@example.com', + ) + mocker.patch( + 'confidant.routes.services.acl_module_check', + return_value=False, + ) + ret = app.test_client().get( + '/v1/services/something-production-iad/credentials', + follow_redirects=False + ) + assert ret.status_code == 403 + + mocker.patch( + 'confidant.routes.services.acl_module_check', + return_value=True, + ) + mocker.patch( + 'confidant.services.credentialmanager.Credential.batch_get', + return_value=credential_list, + ) + mocker.patch( + 'confidant.models.credential.Credential._get_decrypted_credential_pairs', + return_value={}, + ) + mocker.patch( + 'confidant.models.service.Service.get', + return_value=service, + ) + ret = app.test_client().get( + '/v1/services/something-production-iad/credentials', + follow_redirects=False + ) + cred_ids = ['1234', '5678'] + json_data = json.loads(ret.data) + assert ret.status_code == 200 + assert len(json_data['credentials']) == len(cred_ids) + assert json_data['next_page'] is None + assert json_data['credentials'][0]['id'] in cred_ids + assert json_data['credentials'][1]['id'] in cred_ids + + mocker.patch( + 'confidant.services.credentialmanager.Credential.batch_get', + return_value=[credential_list[0]], + ) + ret = app.test_client().get( + '/v1/services/something-production-iad/credentials?limit=1', + follow_redirects=False + ) + json_data = json.loads(ret.data) + assert ret.status_code == 200 + assert len(json_data['credentials']) == 1 + + mocker.patch( + 'confidant.services.credentialmanager.Credential.batch_get', + return_value=[credential_list[1]], + ) + ret = app.test_client().get( + '/v1/services/something-production-iad/credentials?limit=1&page=2', + follow_redirects=False + ) + json_data = json.loads(ret.data) + assert ret.status_code == 200 + assert len(json_data['credentials']) == 1 From 710d1e4974d05ce860ea7fb443d920a7d99107c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erick=20Dur=C3=A1n?= Date: Fri, 27 Jan 2023 16:54:30 -0800 Subject: [PATCH 4/5] fix lint --- tests/unit/confidant/routes/service_credentials_test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/confidant/routes/service_credentials_test.py b/tests/unit/confidant/routes/service_credentials_test.py index 21b02956..c79f4599 100644 --- a/tests/unit/confidant/routes/service_credentials_test.py +++ b/tests/unit/confidant/routes/service_credentials_test.py @@ -84,7 +84,8 @@ def test_get_services_list(mocker, service, credential_list): return_value=credential_list, ) mocker.patch( - 'confidant.models.credential.Credential._get_decrypted_credential_pairs', + 'confidant.models.credential.' + 'Credential._get_decrypted_credential_pairs', return_value={}, ) mocker.patch( From 68832dd811cdbfd9505dc1a2874bad64444202db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erick=20Dur=C3=A1n?= Date: Fri, 27 Jan 2023 16:55:24 -0800 Subject: [PATCH 5/5] fix lint --- tests/unit/confidant/routes/service_credentials_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/confidant/routes/service_credentials_test.py b/tests/unit/confidant/routes/service_credentials_test.py index c79f4599..32efb177 100644 --- a/tests/unit/confidant/routes/service_credentials_test.py +++ b/tests/unit/confidant/routes/service_credentials_test.py @@ -1,7 +1,6 @@ import json import pytest -from unittest import mock from datetime import datetime from confidant.app import create_app