diff --git a/api-ref/source/certificates.inc b/api-ref/source/certificates.inc index 18ff87cb2e..59276e2be2 100644 --- a/api-ref/source/certificates.inc +++ b/api-ref/source/certificates.inc @@ -118,3 +118,29 @@ Response Example .. literalinclude:: samples/certificates-ca-sign-resp.json :language: javascript + +Rotate the CA certificate for a bay/cluster +=========================================== + +.. rest_method:: PATCH /v1/certificates/{bay_uuid/cluster_uuid} + +Rotate the CA certificate for a bay/cluster and invalidate all user +certificates. + +Response Codes +-------------- + +.. rest_status_code:: success status.yaml + + - 202 + +.. rest_status_code:: error status.yaml + + - 400 + +Request +------- + +.. rest_parameters:: parameters.yaml + + - cluster: cluster_id diff --git a/etc/magnum/policy.json b/etc/magnum/policy.json index ea9f7dd0b9..c7f82c8283 100644 --- a/etc/magnum/policy.json +++ b/etc/magnum/policy.json @@ -37,6 +37,7 @@ "certificate:create": "rule:admin_or_user", "certificate:get": "rule:admin_or_user", + "certificate:rotate_ca": "rule:admin_or_owner", "magnum-service:get_all": "rule:admin_api", "stats:get_all": "rule:admin_or_owner" diff --git a/magnum/api/controllers/v1/certificate.py b/magnum/api/controllers/v1/certificate.py index 432ad18200..f399148f2d 100644 --- a/magnum/api/controllers/v1/certificate.py +++ b/magnum/api/controllers/v1/certificate.py @@ -166,3 +166,15 @@ class CertificateController(base.Controller): new_cert = pecan.request.rpcapi.sign_certificate(cluster, cert_obj) return Certificate.convert_with_links(new_cert) + + @base.Controller.api_version("1.5") + @expose.expose(None, types.uuid_or_name, status_code=202) + def patch(self, cluster_ident): + context = pecan.request.context + cluster = api_utils.get_resource('Cluster', cluster_ident) + policy.enforce(context, 'certificate:rotate_ca', cluster, + action='certificate:rotate_ca') + if cluster.cluster_template.tls_disabled: + raise exception.NotSupported("Rotating the CA certificate on a " + "non-TLS cluster is not supported") + pecan.request.rpcapi.rotate_ca_certificate(cluster) diff --git a/magnum/api/controllers/versions.py b/magnum/api/controllers/versions.py index caecfab2f7..da215ba834 100644 --- a/magnum/api/controllers/versions.py +++ b/magnum/api/controllers/versions.py @@ -37,10 +37,11 @@ REST_API_VERSION_HISTORY = """REST API Version History: * 1.2 - Async bay operations support * 1.3 - Add bay rollback support * 1.4 - Add stats API + * 1.5 - Add cluster CA certificate rotation support """ BASE_VER = '1.1' -CURRENT_MAX_VER = '1.4' +CURRENT_MAX_VER = '1.5' class Version(object): diff --git a/magnum/api/rest_api_version_history.rst b/magnum/api/rest_api_version_history.rst index 69d4792418..a426da3253 100644 --- a/magnum/api/rest_api_version_history.rst +++ b/magnum/api/rest_api_version_history.rst @@ -57,3 +57,12 @@ user documentation. - http://XXX/v1/stats or - http://XXX/v1/stats?project_id= or - http://XXX/v1/stats?project_id=&type= + + +1.5 +--- + + Support for cluster CA certificate rotation + + This gives admins a way to revoke access to an existing cluster once + a user has been granted access. diff --git a/magnum/conductor/api.py b/magnum/conductor/api.py index 4d70616140..d973d0eebe 100644 --- a/magnum/conductor/api.py +++ b/magnum/conductor/api.py @@ -58,6 +58,9 @@ class API(rpc_service.API): def get_ca_certificate(self, cluster): return self._call('get_ca_certificate', cluster=cluster) + def rotate_ca_certificate(self, cluster): + return self._call('rotate_ca_certificate', cluster=cluster) + # Versioned Objects indirection API def object_class_action(self, context, objname, objmethod, objver, diff --git a/magnum/conductor/handlers/ca_conductor.py b/magnum/conductor/handlers/ca_conductor.py index da96979cb0..f55ff4a7f4 100644 --- a/magnum/conductor/handlers/ca_conductor.py +++ b/magnum/conductor/handlers/ca_conductor.py @@ -16,6 +16,7 @@ from oslo_log import log as logging from magnum.conductor.handlers.common import cert_manager +from magnum.drivers.common import driver from magnum import objects LOG = logging.getLogger(__name__) @@ -45,3 +46,8 @@ class Handler(object): certificate = objects.Certificate.from_object_cluster(cluster) certificate.pem = ca_cert.get_certificate() return certificate + + def rotate_ca_certificate(self, context, cluster): + cluster_driver = driver.Driver.get_driver_for_cluster(context, + cluster) + cluster_driver.rotate_ca_certificate(context, cluster) diff --git a/magnum/drivers/common/driver.py b/magnum/drivers/common/driver.py index b28804936c..204a91f649 100644 --- a/magnum/drivers/common/driver.py +++ b/magnum/drivers/common/driver.py @@ -184,3 +184,7 @@ class Driver(object): """return the scale manager for this driver.""" return None + + def rotate_ca_certificate(self, context, cluster): + raise exception.NotSupported( + "'rotate_ca_certificate' is not supported by this driver.") diff --git a/magnum/tests/functional/api/v1/clients/cert_client.py b/magnum/tests/functional/api/v1/clients/cert_client.py index 835484a20a..6853f35956 100644 --- a/magnum/tests/functional/api/v1/clients/cert_client.py +++ b/magnum/tests/functional/api/v1/clients/cert_client.py @@ -38,7 +38,7 @@ class CertClient(client.MagnumClient): :returns: response object and ClusterCollection object """ - resp, body = self.get(self.cert_uri(cluster_id)) + resp, body = self.get(self.cert_uri(cluster_id), **kwargs) return self.deserialize(resp, body, cert_model.CertEntity) def post_cert(self, model, **kwargs): diff --git a/magnum/tests/functional/api/v1/test_cluster.py b/magnum/tests/functional/api/v1/test_cluster.py index 12c7e72e89..3a0843e309 100644 --- a/magnum/tests/functional/api/v1/test_cluster.py +++ b/magnum/tests/functional/api/v1/test_cluster.py @@ -23,6 +23,11 @@ from magnum.tests.functional.common import config from magnum.tests.functional.common import datagen +HEADERS = {'OpenStack-API-Version': 'container-infra latest', + 'Accept': 'application/json', + 'Content-Type': 'application/json'} + + class ClusterTest(base.BaseTempestTest): """Tests for cluster CRUD.""" @@ -118,7 +123,7 @@ class ClusterTest(base.BaseTempestTest): self.assertEqual(204, resp.status) self.cluster_client.wait_for_cluster_to_delete(cluster_id) self.assertRaises(exceptions.NotFound, self.cert_client.get_cert, - cluster_id) + cluster_id, headers=HEADERS) return resp, model def _get_cluster_by_id(self, cluster_id): @@ -153,7 +158,7 @@ class ClusterTest(base.BaseTempestTest): # test ca show resp, cert_model = self.cert_client.get_cert( - cluster_model.uuid) + cluster_model.uuid, headers=HEADERS) self.LOG.debug("cert resp: %s" % resp) self.assertEqual(200, resp.status) self.assertEqual(cert_model.cluster_uuid, cluster_model.uuid) @@ -179,7 +184,8 @@ Q0uA0aVog3f5iJxCa3Hp5gxbJQ6zV6kJ0TEsuaaOhEko9sdpCoPOnRBm2i/XRD2D cert_data_model = datagen.cert_data(cluster_model.uuid, csr_data=csr_sample) - resp, cert_model = self.cert_client.post_cert(cert_data_model) + resp, cert_model = self.cert_client.post_cert(cert_data_model, + headers=HEADERS) self.LOG.debug("cert resp: %s" % resp) self.assertEqual(201, resp.status) self.assertEqual(cert_model.cluster_uuid, cluster_model.uuid) @@ -193,7 +199,7 @@ Q0uA0aVog3f5iJxCa3Hp5gxbJQ6zV6kJ0TEsuaaOhEko9sdpCoPOnRBm2i/XRD2D self.assertRaises( exceptions.BadRequest, self.cert_client.post_cert, - cert_data_model) + cert_data_model, headers=HEADERS) # test cluster delete self._delete_cluster(cluster_model.uuid) diff --git a/magnum/tests/functional/python_client_base.py b/magnum/tests/functional/python_client_base.py index e253273369..4e5cb944c8 100644 --- a/magnum/tests/functional/python_client_base.py +++ b/magnum/tests/functional/python_client_base.py @@ -112,7 +112,8 @@ class BaseMagnumClient(base.BaseMagnumTest): project_domain_id=project_domain_id, service_type='container-infra', region_name=region_name, - magnum_url=magnum_url) + magnum_url=magnum_url, + api_version='latest') cls.keystone = ksclient.Client(username=user, password=passwd, project_name=project_name, diff --git a/magnum/tests/unit/api/controllers/test_root.py b/magnum/tests/unit/api/controllers/test_root.py index 01b3693ee1..f95a00c288 100644 --- a/magnum/tests/unit/api/controllers/test_root.py +++ b/magnum/tests/unit/api/controllers/test_root.py @@ -40,7 +40,7 @@ class TestRootController(api_base.FunctionalTest): [{u'href': u'http://localhost/v1/', u'rel': u'self'}], u'status': u'CURRENT', - u'max_version': u'1.4', + u'max_version': u'1.5', u'min_version': u'1.1'}]} self.v1_expected = { diff --git a/magnum/tests/unit/api/controllers/v1/test_certificate.py b/magnum/tests/unit/api/controllers/v1/test_certificate.py index c69c699c51..cf84e0cb61 100644 --- a/magnum/tests/unit/api/controllers/v1/test_certificate.py +++ b/magnum/tests/unit/api/controllers/v1/test_certificate.py @@ -20,6 +20,9 @@ from magnum.tests.unit.api import utils as api_utils from magnum.tests.unit.objects import utils as obj_utils +HEADERS = {'OpenStack-API-Version': 'container-infra latest'} + + class TestCertObject(base.TestCase): @mock.patch('magnum.api.utils.get_resource') @@ -36,10 +39,10 @@ class TestCertObject(base.TestCase): self.assertEqual(cert_dict['pem'], cert.pem) -class TestGetCertificate(api_base.FunctionalTest): +class TestGetCaCertificate(api_base.FunctionalTest): def setUp(self): - super(TestGetCertificate, self).setUp() + super(TestGetCaCertificate, self).setUp() self.cluster = obj_utils.create_test_cluster(self.context) conductor_api_patcher = mock.patch('magnum.conductor.api.API') @@ -54,7 +57,8 @@ class TestGetCertificate(api_base.FunctionalTest): mock_cert.as_dict.return_value = fake_cert self.conductor_api.get_ca_certificate.return_value = mock_cert - response = self.get_json('/certificates/%s' % self.cluster.uuid) + response = self.get_json('/certificates/%s' % self.cluster.uuid, + headers=HEADERS) self.assertEqual(self.cluster.uuid, response['cluster_uuid']) # check that bay is still valid as well @@ -68,7 +72,8 @@ class TestGetCertificate(api_base.FunctionalTest): mock_cert.as_dict.return_value = fake_cert self.conductor_api.get_ca_certificate.return_value = mock_cert - response = self.get_json('/certificates/%s' % self.cluster.name) + response = self.get_json('/certificates/%s' % self.cluster.name, + headers=HEADERS) self.assertEqual(self.cluster.uuid, response['cluster_uuid']) # check that bay is still valid as well @@ -78,7 +83,7 @@ class TestGetCertificate(api_base.FunctionalTest): def test_get_one_by_name_not_found(self): response = self.get_json('/certificates/not_found', - expect_errors=True) + expect_errors=True, headers=HEADERS) self.assertEqual(404, response.status_int) self.assertEqual('application/json', response.content_type) @@ -91,7 +96,7 @@ class TestGetCertificate(api_base.FunctionalTest): uuid=uuidutils.generate_uuid()) response = self.get_json('/certificates/test_cluster', - expect_errors=True) + expect_errors=True, headers=HEADERS) self.assertEqual(409, response.status_int) self.assertEqual('application/json', response.content_type) @@ -103,7 +108,8 @@ class TestGetCertificate(api_base.FunctionalTest): mock_cert.as_dict.return_value = fake_cert self.conductor_api.get_ca_certificate.return_value = mock_cert - response = self.get_json('/certificates/%s' % self.cluster.uuid) + response = self.get_json('/certificates/%s' % self.cluster.uuid, + headers=HEADERS) self.assertIn('links', response.keys()) self.assertEqual(2, len(response['links'])) @@ -136,7 +142,7 @@ class TestPost(api_base.FunctionalTest): new_cert = api_utils.cert_post_data(cluster_uuid=self.cluster.uuid) del new_cert['pem'] - response = self.post_json('/certificates', new_cert) + response = self.post_json('/certificates', new_cert, headers=HEADERS) self.assertEqual('application/json', response.content_type) self.assertEqual(201, response.status_int) self.assertEqual(new_cert['cluster_uuid'], @@ -152,7 +158,7 @@ class TestPost(api_base.FunctionalTest): new_cert['bay_uuid'] = new_cert['cluster_uuid'] del new_cert['cluster_uuid'] - response = self.post_json('/certificates', new_cert) + response = self.post_json('/certificates', new_cert, headers=HEADERS) self.assertEqual('application/json', response.content_type) self.assertEqual(201, response.status_int) self.assertEqual(self.cluster.uuid, response.json['cluster_uuid']) @@ -164,7 +170,7 @@ class TestPost(api_base.FunctionalTest): new_cert = api_utils.cert_post_data(cluster_uuid=self.cluster.name) del new_cert['pem'] - response = self.post_json('/certificates', new_cert) + response = self.post_json('/certificates', new_cert, headers=HEADERS) self.assertEqual('application/json', response.content_type) self.assertEqual(201, response.status_int) @@ -176,13 +182,65 @@ class TestPost(api_base.FunctionalTest): del new_cert['pem'] response = self.post_json('/certificates', new_cert, - expect_errors=True) + expect_errors=True, headers=HEADERS) self.assertEqual(400, response.status_int) self.assertEqual('application/json', response.content_type) self.assertTrue(response.json['errors']) +class TestRotateCaCertificate(api_base.FunctionalTest): + + def setUp(self): + super(TestRotateCaCertificate, self).setUp() + self.cluster = obj_utils.create_test_cluster(self.context) + + conductor_api_patcher = mock.patch('magnum.conductor.api.API') + self.conductor_api_class = conductor_api_patcher.start() + self.conductor_api = mock.MagicMock() + self.conductor_api_class.return_value = self.conductor_api + self.addCleanup(conductor_api_patcher.stop) + + def test_rotate_ca_cert(self): + fake_cert = api_utils.cert_post_data() + mock_cert = mock.MagicMock() + mock_cert.as_dict.return_value = fake_cert + self.conductor_api.rotate_ca_certificate.return_value = mock_cert + + response = self.patch_json('/certificates/%s' % self.cluster.uuid, + params={}, headers=HEADERS) + + self.assertEqual(202, response.status_code) + + +class TestRotateCaCertificateNonTls(api_base.FunctionalTest): + + def setUp(self): + super(TestRotateCaCertificateNonTls, self).setUp() + self.cluster_template = obj_utils.create_test_cluster_template( + self.context, tls_disabled=True) + self.cluster = obj_utils.create_test_cluster(self.context) + + conductor_api_patcher = mock.patch('magnum.conductor.api.API') + self.conductor_api_class = conductor_api_patcher.start() + self.conductor_api = mock.MagicMock() + self.conductor_api_class.return_value = self.conductor_api + self.addCleanup(conductor_api_patcher.stop) + + def test_rotate_ca_cert_non_tls(self): + fake_cert = api_utils.cert_post_data() + mock_cert = mock.MagicMock() + mock_cert.as_dict.return_value = fake_cert + self.conductor_api.rotate_ca_certificate.return_value = mock_cert + + response = self.patch_json('/certificates/%s' % self.cluster.uuid, + params={}, headers=HEADERS, + expect_errors=True) + self.assertEqual(400, response.status_code) + self.assertIn("Rotating the CA certificate on a non-TLS cluster", + response.json['errors'][0]['detail']) + + class TestCertPolicyEnforcement(api_base.FunctionalTest): def _common_policy_check(self, rule, func, *arg, **kwarg): @@ -199,11 +257,18 @@ class TestCertPolicyEnforcement(api_base.FunctionalTest): self._common_policy_check( "certificate:get", self.get_json, '/certificates/%s' % cluster.uuid, - expect_errors=True) + expect_errors=True, headers=HEADERS) def test_policy_disallow_create(self): cluster = obj_utils.create_test_cluster(self.context) cert = api_utils.cert_post_data(cluster_uuid=cluster.uuid) self._common_policy_check( "certificate:create", self.post_json, '/certificates', cert, - expect_errors=True) + expect_errors=True, headers=HEADERS) + + def test_policy_disallow_rotate(self): + cluster = obj_utils.create_test_cluster(self.context) + self._common_policy_check( + "certificate:rotate_ca", self.patch_json, + '/certificates/%s' % cluster.uuid, params={}, expect_errors=True, + headers=HEADERS) diff --git a/magnum/tests/unit/objects/utils.py b/magnum/tests/unit/objects/utils.py index d8cbbe280a..6f72bf6b15 100644 --- a/magnum/tests/unit/objects/utils.py +++ b/magnum/tests/unit/objects/utils.py @@ -84,7 +84,8 @@ def create_test_cluster(context, **kw): """ cluster = get_test_cluster(context, **kw) create_test_cluster_template(context, uuid=cluster['cluster_template_id'], - coe=kw.get('coe', 'swarm')) + coe=kw.get('coe', 'swarm'), + tls_disabled=kw.get('tls_disabled')) cluster.create() return cluster diff --git a/releasenotes/notes/rotate-cluster-cert-9f84deb0adf9afb1.yaml b/releasenotes/notes/rotate-cluster-cert-9f84deb0adf9afb1.yaml new file mode 100644 index 0000000000..e49f18a1ad --- /dev/null +++ b/releasenotes/notes/rotate-cluster-cert-9f84deb0adf9afb1.yaml @@ -0,0 +1,5 @@ +--- +features: + - Add microversion 1.5 to support rotation of a cluster's CA + certificate. This gives admins a way to restrict/deny access to + an existing cluster once a user has been granted access.