From 33b104b11ff6f20ec7cbafb34ea500883776faa2 Mon Sep 17 00:00:00 2001 From: Isaac Mungai Date: Tue, 19 Jul 2016 11:40:53 -0400 Subject: [PATCH] Enforce san cert hostname limit on cert create Add settings to cert_info_storage Add settings admin endpoints Add limit check log to taskflow and provider layers Change-Id: Ie0db8f962363590a70bc387ca9387e2fb22829b1 --- poppy/manager/default/ssl_certificate.py | 31 +++++++ .../cert_info_storage/cassandra_storage.py | 71 ++++++++++++++- poppy/provider/akamai/certificates.py | 23 +++++ poppy/transport/pecan/controllers/v1/admin.py | 65 ++++++++------ poppy/transport/validators/helpers.py | 31 +++++++ .../validators/schemas/ssl_certificate.py | 5 ++ .../transport/pecan/controllers/test_admin.py | 88 +++++++++++++++++++ .../manager/default/test_ssl_certificate.py | 25 +++++- .../test_cassandra_cert_info_storage.py | 30 ++++++- .../unit/provider/akamai/test_certificates.py | 25 +++++- 10 files changed, 363 insertions(+), 31 deletions(-) create mode 100644 tests/functional/transport/pecan/controllers/test_admin.py diff --git a/poppy/manager/default/ssl_certificate.py b/poppy/manager/default/ssl_certificate.py index 68ab8660..5d07099d 100644 --- a/poppy/manager/default/ssl_certificate.py +++ b/poppy/manager/default/ssl_certificate.py @@ -344,6 +344,37 @@ class DefaultSSLCertificateController(base.SSLCertificateController): return res + def get_san_cert_hostname_limit(self): + if 'akamai' in self._driver.providers: + akamai_driver = self._driver.providers['akamai'].obj + res = akamai_driver.cert_info_storage.get_san_cert_hostname_limit() + res = {'san_cert_hostname_limit': res} + else: + # if not using akamai driver just return an empty list + res = {'san_cert_hostname_limit': 0} + + return res + + def set_san_cert_hostname_limit(self, request_json): + if 'akamai' in self._driver.providers: + try: + new_limit = request_json['san_cert_hostname_limit'] + except Exception as exc: + LOG.error("Error attempting to update san settings {0}".format( + exc + )) + raise ValueError('Unknown setting!') + + akamai_driver = self._driver.providers['akamai'].obj + res = akamai_driver.cert_info_storage.set_san_cert_hostname_limit( + new_limit + ) + else: + # if not using akamai driver just return an empty list + res = 0 + + return res + def get_certs_by_status(self, status): certs_by_status = self.storage.get_certs_by_status(status) diff --git a/poppy/provider/akamai/cert_info_storage/cassandra_storage.py b/poppy/provider/akamai/cert_info_storage/cassandra_storage.py index ecb7ff9f..c2b14cb9 100644 --- a/poppy/provider/akamai/cert_info_storage/cassandra_storage.py +++ b/poppy/provider/akamai/cert_info_storage/cassandra_storage.py @@ -130,7 +130,8 @@ class CassandraSanInfoStorage(base.BaseAkamaiSanInfoStorage): stmt = query.SimpleStatement( GET_PROVIDER_INFO, - consistency_level=self.consistency_level) + consistency_level=self.consistency_level + ) results = self.session.execute(stmt, args) complete_results = list(results) if len(complete_results) != 1: @@ -143,6 +144,39 @@ class CassandraSanInfoStorage(base.BaseAkamaiSanInfoStorage): def _get_akamai_san_certs_info(self): return json.loads(self._get_akamai_provider_info()['info']['san_info']) + def _get_akamai_san_certs_settings(self): + try: + return json.loads( + self._get_akamai_provider_info()['info']['settings'] + ) + except KeyError as ke: + LOG.error( + 'Error retrieving cert info storage settings. {0}'.format(ke) + ) + # settings doesn't exist in the table + self._seed_san_info_settings() + + return json.loads(self._get_akamai_provider_info()['info']['settings']) + + def _seed_san_info_settings(self): + provider_info = dict(self._get_akamai_provider_info()['info']) + provider_info['settings'] = json.dumps( + { + 'san_cert_hostname_limit': 80 + } + ) + + stmt = query.SimpleStatement( + UPDATE_PROVIDER_INFO, + consistency_level=self.consistency_level) + + args = { + 'provider_name': 'akamai', + 'info': provider_info + } + + self.session.execute(stmt, args) + def list_all_san_cert_names(self): return self._get_akamai_san_certs_info().keys() @@ -279,3 +313,38 @@ class CassandraSanInfoStorage(base.BaseAkamaiSanInfoStorage): } self.session.execute(stmt, args) + + def get_san_cert_hostname_limit(self): + """Get the san cert hostname limit setting. + + :returns the hostname limit if the limit exists else None. + """ + + return self._get_akamai_san_certs_settings().get( + 'san_cert_hostname_limit' + ) + + def set_san_cert_hostname_limit(self, new_hostname_limit): + settings = self._get_akamai_san_certs_settings() + + if settings is None: + raise ValueError('No san cert settings found.') + + settings['san_cert_hostname_limit'] = new_hostname_limit + + # Change the previous san info in the overall provider_info dictionary + provider_info = dict(self._get_akamai_provider_info()['info']) + provider_info['settings'] = json.dumps(settings) + + stmt = query.SimpleStatement( + UPDATE_PROVIDER_INFO, + consistency_level=self.consistency_level + ) + + args = { + 'provider_name': 'akamai', + 'info': provider_info + } + + self.session.execute(stmt, args) + return self.get_san_cert_hostname_limit() diff --git a/poppy/provider/akamai/certificates.py b/poppy/provider/akamai/certificates.py index 688ba5f6..a7f3f0b6 100644 --- a/poppy/provider/akamai/certificates.py +++ b/poppy/provider/akamai/certificates.py @@ -88,6 +88,10 @@ class CertificateController(base.CertificateBase): ) }) + san_cert_hostname_limit = ( + self.cert_info_storage.get_san_cert_hostname_limit() + ) + for san_cert_name in self.san_cert_cnames: enabled = ( self.cert_info_storage.get_enabled_status( @@ -96,6 +100,25 @@ class CertificateController(base.CertificateBase): ) if not enabled: continue + + # if the limit provided as an arg to this function is None + # default san_cert_hostname_limit to the value provided in + # the config file. + san_cert_hostname_limit = ( + san_cert_hostname_limit or + self.driver.san_cert_hostname_limit + ) + + # Check san_cert to enforce number of hosts hasn't + # reached the limit. If the current san_cert is at max + # capacity continue to the next san_cert + san_hosts = utils.get_ssl_number_of_hosts( + san_cert_name + + self.driver.akamai_https_access_url_suffix + ) + if san_hosts >= san_cert_hostname_limit: + continue + last_sps_id = ( self.cert_info_storage.get_cert_last_spsid( san_cert_name diff --git a/poppy/transport/pecan/controllers/v1/admin.py b/poppy/transport/pecan/controllers/v1/admin.py index db06bae5..28e03a22 100644 --- a/poppy/transport/pecan/controllers/v1/admin.py +++ b/poppy/transport/pecan/controllers/v1/admin.py @@ -216,36 +216,43 @@ class AkamaiRetryListController(base.Controller, hooks.HookController): res, deleted = ( self._driver.manager.ssl_certificate_controller. update_san_retry_list(queue_data)) + # queue is the new queue, and deleted is deleted items + return {"queue": res, "deleted": deleted} except Exception as e: pecan.abort(400, str(e)) - # queue is the new queue, and deleted is deleted items - return {"queue": res, "deleted": deleted} - class AkamaiSanCertConfigController(base.Controller, hooks.HookController): __hooks__ = [poppy_hooks.Context(), poppy_hooks.Error()] @pecan.expose('json') @decorators.validate( - san_cert_name=rule.Rule( - helpers.is_valid_domain_by_name(), + query=rule.Rule( + helpers.is_valid_domain_by_name_or_akamai_setting(), helpers.abort_with_message)) - def get_one(self, san_cert_name): + def get_one(self, query): - try: - res = ( - self._driver.manager.ssl_certificate_controller. - get_san_cert_configuration(san_cert_name)) - except Exception as e: - pecan.abort(400, str(e)) - - return res + if query == 'san_cert_hostname_limit': + try: + return ( + self._driver.manager.ssl_certificate_controller. + get_san_cert_hostname_limit() + ) + except Exception as e: + pecan.abort(400, str(e)) + else: + try: + return ( + self._driver.manager.ssl_certificate_controller. + get_san_cert_configuration(query) + ) + except Exception as e: + pecan.abort(400, str(e)) @pecan.expose('json') @decorators.validate( - san_cert_name=rule.Rule( - helpers.is_valid_domain_by_name(), + query=rule.Rule( + helpers.is_valid_domain_by_name_or_akamai_setting(), helpers.abort_with_message), request=rule.Rule( helpers.json_matches_service_schema( @@ -253,17 +260,25 @@ class AkamaiSanCertConfigController(base.Controller, hooks.HookController): "config", "POST")), helpers.abort_with_message, stoplight_helpers.pecan_getter)) - def post(self, san_cert_name): - config_json = json.loads(pecan.request.body.decode('utf-8')) + def post(self, query): + request_json = json.loads(pecan.request.body.decode('utf-8')) - try: - res = ( - self._driver.manager.ssl_certificate_controller. - update_san_cert_configuration(san_cert_name, config_json)) - except Exception as e: - pecan.abort(400, str(e)) + if query == 'san_cert_hostname_limit': + try: + self._driver.manager.ssl_certificate_controller. \ + set_san_cert_hostname_limit(request_json) - return res + return pecan.Response(None, 202) + except Exception as e: + pecan.abort(400, str(e)) + else: + try: + res = ( + self._driver.manager.ssl_certificate_controller. + update_san_cert_configuration(query, request_json)) + return res + except Exception as e: + pecan.abort(400, str(e)) class AkamaiSSLCertificateController(base.Controller, hooks.HookController): diff --git a/poppy/transport/validators/helpers.py b/poppy/transport/validators/helpers.py index 4d968299..e5c7e36d 100644 --- a/poppy/transport/validators/helpers.py +++ b/poppy/transport/validators/helpers.py @@ -189,6 +189,37 @@ def is_valid_project_id(project_id): '{0}'.format(project_id)) +@decorators.validation_function +def is_valid_akamai_setting(setting): + if setting not in ['san_cert_hostname_limit']: + raise exceptions.ValidationFailed( + 'Invalid akamai setting : {0}'.format(setting) + ) + + +@decorators.validation_function +def is_valid_domain_by_name_or_akamai_setting(query): + valid_domain = True + domain_exc = None + valid_setting = True + setting_exc = None + + try: + is_valid_domain_by_name(query) + except Exception as exc: + valid_domain = False + domain_exc = exc + + try: + is_valid_akamai_setting(query) + except Exception as exc: + valid_setting = False + setting_exc = exc + + if valid_domain is False and valid_setting is False: + raise exceptions.ValidationFailed(str(domain_exc) + str(setting_exc)) + + def is_root_domain(domain): domain_name = domain.get('domain') diff --git a/poppy/transport/validators/schemas/ssl_certificate.py b/poppy/transport/validators/schemas/ssl_certificate.py index d65f8b50..6a6f5aa1 100644 --- a/poppy/transport/validators/schemas/ssl_certificate.py +++ b/poppy/transport/validators/schemas/ssl_certificate.py @@ -100,6 +100,11 @@ class SSLCertificateSchema(schema_base.SchemaBase): }, 'enabled': { 'type': 'boolean' + }, + 'san_cert_hostname_limit': { + 'type': 'integer', + 'minimum': 1, + 'maximum': 200, } } } diff --git a/tests/functional/transport/pecan/controllers/test_admin.py b/tests/functional/transport/pecan/controllers/test_admin.py new file mode 100644 index 00000000..47aa5444 --- /dev/null +++ b/tests/functional/transport/pecan/controllers/test_admin.py @@ -0,0 +1,88 @@ +# Copyright (c) 2016 Rackspace, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import uuid + +from tests.functional.transport.pecan import base + + +class AdminControllerTest(base.FunctionalTest): + + def setUp(self): + super(AdminControllerTest, self).setUp() + + self.project_id = str(uuid.uuid1()) + + def tearDown(self): + super(AdminControllerTest, self).tearDown() + + def test_put_settings_positive(self): + settings_json = { + 'san_cert_hostname_limit': 10 + } + + # create with good data + response = self.app.post( + '/v1.0/admin/provider/akamai/ssl_certificate/' + 'config/san_cert_hostname_limit', + params=json.dumps(settings_json), + headers={ + 'Content-Type': 'application/json', + 'X-Project-ID': self.project_id + } + ) + self.assertEqual(202, response.status_code) + + def test_put_akamai_settings_negative(self): + settings_json = { + 'san_cert_hostname_limit': 10 + } + + # create with bad endpoint which fails validation with 400 error + response = self.app.post( + '/v1.0/admin/provider/akamai/ssl_certificate/' + 'config/unknown_setting', + params=json.dumps(settings_json), + headers={ + 'Content-Type': 'application/json', + 'X-Project-ID': self.project_id + }, + expect_errors=True + ) + self.assertEqual(400, response.status_code) + + def test_get_settings_positive(self): + response = self.app.get( + '/v1.0/admin/provider/akamai/ssl_certificate/' + 'config/san_cert_hostname_limit', + headers={ + 'Content-Type': 'application/json', + 'X-Project-ID': self.project_id + } + ) + self.assertEqual(200, response.status_code) + + def test_get_akamai_settings_negative(self): + response = self.app.get( + '/v1.0/admin/provider/akamai/ssl_certificate/' + 'config/unknown_setting', + headers={ + 'Content-Type': 'application/json', + 'X-Project-ID': self.project_id + }, + expect_errors=True + ) + self.assertEqual(400, response.status_code) diff --git a/tests/unit/manager/default/test_ssl_certificate.py b/tests/unit/manager/default/test_ssl_certificate.py index c2528d62..91235c19 100644 --- a/tests/unit/manager/default/test_ssl_certificate.py +++ b/tests/unit/manager/default/test_ssl_certificate.py @@ -114,7 +114,7 @@ class DefaultSSLCertificateControllerTests(base.TestCase): with testtools.ExpectedException(ValueError): self.scc.get_san_cert_configuration("non-existant") - def test_update_san_cert_configuration_positive(self): + def test_set_san_cert_hostname_limit_positive(self): resp = mock.Mock() resp.status_code = 200 resp.json.return_value = { @@ -136,6 +136,29 @@ class DefaultSSLCertificateControllerTests(base.TestCase): ) ) + def test_update_san_cert_configuration_positive(self): + + self.scc.set_san_cert_hostname_limit( + {"san_cert_hostname_limit": '1234'} + ) + + cert_info_storage = self.mock_providers['akamai'].obj.cert_info_storage + + cert_info_storage.set_san_cert_hostname_limit.\ + assert_called_once_with('1234') + + def test_update_san_cert_configuration_negative(self): + + with testtools.ExpectedException(ValueError): + self.scc.set_san_cert_hostname_limit( + {"invalid_setting_name": '1234'} + ) + + cert_info_storage = self.mock_providers['akamai'].obj.cert_info_storage + + self.assertFalse( + cert_info_storage.set_san_cert_hostname_limit.called) + def test_update_san_cert_configuration_no_sps_id(self): api_client = self.mock_providers['akamai'].obj.sps_api_client diff --git a/tests/unit/provider/akamai/cert_info_storage/test_cassandra_cert_info_storage.py b/tests/unit/provider/akamai/cert_info_storage/test_cassandra_cert_info_storage.py index 102e9b3c..c01dc677 100644 --- a/tests/unit/provider/akamai/cert_info_storage/test_cassandra_cert_info_storage.py +++ b/tests/unit/provider/akamai/cert_info_storage/test_cassandra_cert_info_storage.py @@ -44,6 +44,7 @@ class TestCassandraCertInfoStorage(base.TestCase): self.conf = cfg.ConfigOpts() + self.default_limit = 80 self.get_returned_value = [{'info': { 'san_info': '{"secure2.san1.test-cdn.com": ' @@ -52,7 +53,9 @@ class TestCassandraCertInfoStorage(base.TestCase): '"secure1.san1.test-cdn.com": ' '{"ipVersion": "ipv4", "issuer": "symentec", ' '"slot_deployment_klass": "esslType", ' - '"jobId": "1432", "spsId": 1423}}'}}] + '"jobId": "1432", "spsId": 1423}}', + 'settings': '{"san_cert_hostname_limit": 80}' + }}] @mock.patch.object( cassandra_storage, @@ -188,3 +191,28 @@ class TestCassandraCertInfoStorage(base.TestCase): cert_name, {'spsId': new_spsId} ) mock_execute.assert_called() + + def test_set_san_cert_hostname_limit(self): + self.cassandra_storage = cassandra_storage.CassandraSanInfoStorage( + self.conf + ) + + mock_execute = self.cassandra_storage.session.execute + mock_execute.return_value = self.get_returned_value + + self.cassandra_storage.set_san_cert_hostname_limit(99) + + mock_execute.assert_called() + + def test_get_san_cert_hostname_limit(self): + self.cassandra_storage = cassandra_storage.CassandraSanInfoStorage( + self.conf + ) + + mock_execute = self.cassandra_storage.session.execute + mock_execute.return_value = self.get_returned_value + + res = self.cassandra_storage.get_san_cert_hostname_limit() + + mock_execute.assert_called() + self.assertEqual(res, self.default_limit) diff --git a/tests/unit/provider/akamai/test_certificates.py b/tests/unit/provider/akamai/test_certificates.py index b5ab5bd6..ecb81288 100644 --- a/tests/unit/provider/akamai/test_certificates.py +++ b/tests/unit/provider/akamai/test_certificates.py @@ -36,13 +36,20 @@ class TestCertificates(base.TestCase): 'example.net' ) - background_job_controller_patcher = mock.patch( + san_by_host_patcher = mock.patch( 'poppy.provider.akamai.utils.get_sans_by_host' ) - self.mock_get_sans_by_host = background_job_controller_patcher.start() - self.addCleanup(background_job_controller_patcher.stop) + self.mock_get_sans_by_host = san_by_host_patcher.start() + self.addCleanup(san_by_host_patcher.stop) + + ssl_number_of_hosts_patcher = mock.patch( + 'poppy.provider.akamai.utils.get_ssl_number_of_hosts' + ) + self.mock_get_ssl_number_of_hosts = ssl_number_of_hosts_patcher.start() + self.addCleanup(ssl_number_of_hosts_patcher.stop) self.mock_get_sans_by_host.return_value = [] + self.mock_get_ssl_number_of_hosts.return_value = 10 self.controller = certificates.CertificateController(self.driver) @@ -77,6 +84,9 @@ class TestCertificates(base.TestCase): 'slot-deployment.class': 'esslType' } + controller.cert_info_storage.get_san_cert_hostname_limit. \ + return_value = 80 + cert_info = controller.cert_info_storage.get_cert_info( "secure.san1.poppycdn.com") cert_info['add.sans'] = "www.abc.com" @@ -149,6 +159,9 @@ class TestCertificates(base.TestCase): controller.cert_info_storage.get_cert_last_spsid( "secure.san1.poppycdn.com")) + controller.cert_info_storage.get_san_cert_hostname_limit. \ + return_value = 80 + controller.cert_info_storage.get_cert_info.return_value = { 'cnameHostname': "secure.san1.poppycdn.com", 'jobId': "secure.san1.poppycdn.com", @@ -246,6 +259,9 @@ class TestCertificates(base.TestCase): controller.cert_info_storage.get_cert_last_spsid( "secure.san1.poppycdn.com")) + controller.cert_info_storage.get_san_cert_hostname_limit. \ + return_value = 80 + controller.cert_info_storage.get_cert_info.return_value = { 'cnameHostname': "secure.san1.poppycdn.com", 'jobId': "secure.san1.poppycdn.com", @@ -303,6 +319,9 @@ class TestCertificates(base.TestCase): controller.cert_info_storage.get_cert_last_spsid( "secure.san1.poppycdn.com")) + controller.cert_info_storage.get_san_cert_hostname_limit. \ + return_value = 80 + controller.cert_info_storage.get_cert_info.return_value = { 'cnameHostname': "secure.san1.poppycdn.com", 'jobId': "secure.san1.poppycdn.com",