Add an API to rotate a cluster CA certificate

This will give admins a way to revoke access to an existing cluster
once a user has been granted access.

Bumped the API microversion to 1.5 for the new endpoint.

Deprecated policy certificate:get in favor of certificate:get_ca for
clarity and consistency.

Depends-On: Ie960464e45445e195e75b91e8d65a4046eb21e93
Implements: blueprint revoke-cluster-cert
Change-Id: Ief28bef3a79f212acf4166e443a96e5419fbb757
This commit is contained in:
Jason Dunsmore 2016-11-28 17:01:43 -06:00
parent 06b97cc7d7
commit a65ef7d3c3
15 changed files with 162 additions and 22 deletions

View File

@ -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

View File

@ -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"

View File

@ -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)

View File

@ -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):

View File

@ -57,3 +57,12 @@ user documentation.
- http://XXX/v1/stats or
- http://XXX/v1/stats?project_id=<project-id> or
- http://XXX/v1/stats?project_id=<project-id>&type=<stats-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.

View File

@ -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,

View File

@ -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)

View File

@ -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.")

View File

@ -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):

View File

@ -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)

View File

@ -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,

View File

@ -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 = {

View File

@ -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)

View File

@ -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

View File

@ -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.