diff --git a/magnum/api/controllers/v1/__init__.py b/magnum/api/controllers/v1/__init__.py index dec6c4f9c0..871092f0a2 100644 --- a/magnum/api/controllers/v1/__init__.py +++ b/magnum/api/controllers/v1/__init__.py @@ -27,6 +27,7 @@ from magnum.api.controllers import link from magnum.api.controllers.v1 import certificate from magnum.api.controllers.v1 import cluster from magnum.api.controllers.v1 import cluster_template +from magnum.api.controllers.v1 import credential from magnum.api.controllers.v1 import federation from magnum.api.controllers.v1 import magnum_services from magnum.api.controllers.v1 import quota @@ -98,6 +99,9 @@ class V1(controllers_base.APIBase): nodegroups = [link.Link] """Links to the nodegroups resource""" + credentials = [link.Link] + """Links to the credentials resource""" + @staticmethod def convert(): v1 = V1() @@ -162,6 +166,12 @@ class V1(controllers_base.APIBase): 'clusters/{cluster_id}', 'nodegroups', bookmark=True)] + v1.credentials = [link.Link.make_link('self', pecan.request.host_url, + 'credentials', ''), + link.Link.make_link('bookmark', + pecan.request.host_url, + 'credentials', '', + bookmark=True)] return v1 @@ -176,6 +186,7 @@ class Controller(controllers_base.Controller): mservices = magnum_services.MagnumServiceController() stats = stats.StatsController() federations = federation.FederationsController() + credentials = credential.CredentialsController() @expose.expose(V1) def get(self): diff --git a/magnum/api/controllers/v1/credential.py b/magnum/api/controllers/v1/credential.py new file mode 100644 index 0000000000..d98e4979b5 --- /dev/null +++ b/magnum/api/controllers/v1/credential.py @@ -0,0 +1,65 @@ +# 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 pecan +from wsme import types as wtypes + +from oslo_log import log + +from magnum.api.controllers import base +from magnum.api.controllers.v1 import types +from magnum.api import expose +from magnum.api import utils as api_utils +from magnum.common import policy + + +LOG = log.getLogger(__name__) + + +class ClusterID(wtypes.Base): + """API representation of a cluster ID + + This class enforces type checking and value constraints, and converts + between the internal object model and the API representation of a cluster + ID. + """ + + uuid = types.uuid + """Unique UUID for this cluster""" + + def __init__(self, uuid): + self.uuid = uuid + + +class CredentialsController(base.Controller): + """REST controller for cluster actions.""" + def __init__(self): + super(CredentialsController, self).__init__() + + @base.Controller.api_version("1.12") + @expose.expose(ClusterID, types.uuid_or_name, status_code=202) + def patch(self, cluster_ident): + """Rotate the credential in use by a cluster. + + :param cluster_ident: UUID of a cluster or logical name of the cluster. + """ + + context = pecan.request.context + policy.enforce(context, 'credential:rotate', + action='credential:rotate') + + cluster = api_utils.get_resource('Cluster', cluster_ident) + # NOTE(northcottmt): Perform rotation synchronously as there aren't any + # slow apply/upgrade operations to do + pecan.request.rpcapi.credential_rotate(cluster) + + return ClusterID(cluster.uuid) diff --git a/magnum/api/controllers/versions.py b/magnum/api/controllers/versions.py index ff60c8a383..57600459ce 100644 --- a/magnum/api/controllers/versions.py +++ b/magnum/api/controllers/versions.py @@ -34,10 +34,11 @@ REST_API_VERSION_HISTORY = """REST API Version History: * 1.9 - Add nodegroup API * 1.10 - Allow nodegroups with 0 nodes * 1.11 - Remove bay and baymodel objects + * 1.12 - Add credential API """ BASE_VER = '1.1' -CURRENT_MAX_VER = '1.11' +CURRENT_MAX_VER = '1.12' class Version(object): diff --git a/magnum/api/rest_api_version_history.rst b/magnum/api/rest_api_version_history.rst index bcbfa3a8c1..d9453b5d96 100644 --- a/magnum/api/rest_api_version_history.rst +++ b/magnum/api/rest_api_version_history.rst @@ -105,7 +105,16 @@ user documentation. Allow the cluster to be created with node_count = 0 as well as to update existing nodegroups to have 0 nodes. + 1.11 --- Drop bay and baymodels objects from magnum source code + + +1.12 +--- + + Add credential API + + Allow the cluster to have its associated credential rotated. diff --git a/magnum/cmd/conductor.py b/magnum/cmd/conductor.py index 039d3ad999..48850fffb6 100644 --- a/magnum/cmd/conductor.py +++ b/magnum/cmd/conductor.py @@ -29,6 +29,7 @@ from magnum.common import short_id from magnum.conductor.handlers import ca_conductor from magnum.conductor.handlers import cluster_conductor from magnum.conductor.handlers import conductor_listener +from magnum.conductor.handlers import credential_conductor from magnum.conductor.handlers import federation_conductor from magnum.conductor.handlers import indirection_api from magnum.conductor.handlers import nodegroup_conductor @@ -54,6 +55,7 @@ def main(): indirection_api.Handler(), cluster_conductor.Handler(), conductor_listener.Handler(), + credential_conductor.Handler(), ca_conductor.Handler(), federation_conductor.Handler(), nodegroup_conductor.Handler(), diff --git a/magnum/common/policies/__init__.py b/magnum/common/policies/__init__.py index f3a0a7dfcb..0049281044 100644 --- a/magnum/common/policies/__init__.py +++ b/magnum/common/policies/__init__.py @@ -18,6 +18,7 @@ from magnum.common.policies import base from magnum.common.policies import certificate from magnum.common.policies import cluster from magnum.common.policies import cluster_template +from magnum.common.policies import credential from magnum.common.policies import federation from magnum.common.policies import magnum_service from magnum.common.policies import nodegroup @@ -31,6 +32,7 @@ def list_rules(): certificate.list_rules(), cluster.list_rules(), cluster_template.list_rules(), + credential.list_rules(), federation.list_rules(), magnum_service.list_rules(), quota.list_rules(), diff --git a/magnum/common/policies/credential.py b/magnum/common/policies/credential.py new file mode 100644 index 0000000000..7d13ad9c4a --- /dev/null +++ b/magnum/common/policies/credential.py @@ -0,0 +1,35 @@ +# All Rights Reserved. +# +# 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. +from oslo_policy import policy + +from magnum.common.policies import base + +rules = [ + policy.DocumentedRuleDefault( + name='credential:rotate', + check_str=base.RULE_PROJECT_MEMBER_DENY_CLUSTER_USER, + scope_types=["project"], + description='Rotate the credential of a cluster.', + operations=[ + { + 'path': '/v1/credentials/{cluster_uuid}', + 'method': 'PATCH' + } + ] + ) +] + + +def list_rules(): + return rules diff --git a/magnum/conductor/api.py b/magnum/conductor/api.py index 8f638bcd98..a05bdb38bd 100644 --- a/magnum/conductor/api.py +++ b/magnum/conductor/api.py @@ -175,6 +175,14 @@ class API(rpc_service.API): def nodegroup_update_async(self, cluster, nodegroup): self._cast('nodegroup_update', cluster=cluster, nodegroup=nodegroup) + # Credential Operations + + def credential_rotate(self, cluster): + return self._call('credential_rotate', cluster=cluster) + + def credential_rotate_async(self, cluster): + return self._cast('credential_rotate', cluster=cluster) + @profiler.trace_cls("rpc") class ListenerAPI(rpc_service.API): diff --git a/magnum/conductor/handlers/credential_conductor.py b/magnum/conductor/handlers/credential_conductor.py new file mode 100644 index 0000000000..b33322330c --- /dev/null +++ b/magnum/conductor/handlers/credential_conductor.py @@ -0,0 +1,58 @@ +# 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. + +from oslo_log import log as logging + +from magnum.common import exception +from magnum.common import profiler +import magnum.conf +from magnum.drivers.common import driver +from magnum.i18n import _ +from magnum.objects import fields + +CONF = magnum.conf.CONF + +LOG = logging.getLogger(__name__) + +ALLOWED_CLUSTER_STATES = { + fields.ClusterStatus.CREATE_COMPLETE, + fields.ClusterStatus.UPDATE_COMPLETE, + fields.ClusterStatus.UPDATE_IN_PROGRESS, + fields.ClusterStatus.UPDATE_FAILED, + fields.ClusterStatus.RESUME_COMPLETE, + fields.ClusterStatus.RESTORE_COMPLETE, + fields.ClusterStatus.ROLLBACK_COMPLETE, + fields.ClusterStatus.SNAPSHOT_COMPLETE, + fields.ClusterStatus.CHECK_COMPLETE, + fields.ClusterStatus.ADOPT_COMPLETE +} + + +@profiler.trace_cls("rpc") +class Handler(object): + + def __init__(self): + super(Handler, self).__init__() + + def credential_rotate(self, context, cluster): + if cluster.status not in ALLOWED_CLUSTER_STATES: + operation = _( + f'{__name__} when cluster status is "{cluster.status}"') + raise exception.NotSupported(operation=operation) + + cluster_driver = driver.Driver.get_driver_for_cluster(context, cluster) + + try: + cluster_driver.rotate_credential(context, cluster) + except NotImplementedError: + raise exception.NotSupported( + message=_("Credential rotation is not supported by driver.")) diff --git a/magnum/drivers/common/driver.py b/magnum/drivers/common/driver.py index 7ac155ec6d..c8f8de064c 100644 --- a/magnum/drivers/common/driver.py +++ b/magnum/drivers/common/driver.py @@ -257,6 +257,10 @@ class Driver(object, metaclass=abc.ABCMeta): raise NotImplementedError("Subclasses must implement " "'delete_nodegroup'.") + def rotate_credential(self, context, cluster): + raise NotImplementedError( + "Driver does not support credential rotate.") + def get_monitor(self, context, cluster): """return the monitor with container data for this driver.""" diff --git a/magnum/tests/unit/api/controllers/test_root.py b/magnum/tests/unit/api/controllers/test_root.py index 97adeead73..9a13a6cc8b 100644 --- a/magnum/tests/unit/api/controllers/test_root.py +++ b/magnum/tests/unit/api/controllers/test_root.py @@ -41,7 +41,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.11', + u'max_version': u'1.12', u'min_version': u'1.1'}]} self.v1_expected = { @@ -71,6 +71,10 @@ class TestRootController(api_base.FunctionalTest): u'rel': u'self'}, {u'href': u'http://localhost/clustertemplates/', u'rel': u'bookmark'}], + u'credentials': [{u'href': u'http://localhost/v1/credentials/', + u'rel': u'self'}, + {u'href': u'http://localhost/credentials/', + u'rel': u'bookmark'}], u'id': u'v1', u'certificates': [{u'href': u'http://localhost/v1/certificates/', u'rel': u'self'}, diff --git a/magnum/tests/unit/api/controllers/v1/test_credential.py b/magnum/tests/unit/api/controllers/v1/test_credential.py new file mode 100644 index 0000000000..04ad95c337 --- /dev/null +++ b/magnum/tests/unit/api/controllers/v1/test_credential.py @@ -0,0 +1,81 @@ +# 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. + +from unittest import mock + +from magnum.conductor import api as rpcapi +from magnum.objects import fields +from magnum.tests.unit.api import base as api_base +from magnum.tests.unit.objects import utils as obj_utils + + +class CredentialControllerTest(api_base.FunctionalTest): + headers = {"Openstack-Api-Version": "container-infra latest"} + + def _add_headers(self, kwargs, roles=None): + if 'headers' not in kwargs: + kwargs['headers'] = self.headers + if roles: + kwargs['headers']['X-Roles'] = ",".join(roles) + + def patch_json(self, *args, **kwargs): + self._add_headers(kwargs, roles=['member']) + return super(CredentialControllerTest, self).patch_json(*args, + **kwargs) + + +class TestPatch(CredentialControllerTest): + def setUp(self): + super(TestPatch, self).setUp() + self.cluster = obj_utils.create_test_cluster(self.context) + p = mock.patch.object(rpcapi.API, 'credential_rotate') + self.mock_rotate = p.start() + self.mock_rotate.side_effect = self._simulate_credential_rotate + self.addCleanup(p.stop) + self.url = f"/credentials/{self.cluster.uuid}" + + def _simulate_credential_rotate(self, cluster): + cluster.status = fields.ClusterStatus.UPDATE_IN_PROGRESS + cluster.save() + + cluster.status = fields.ClusterStatus.UPDATE_COMPLETE + cluster.save() + + def test_rotate(self): + response = self.patch_json(self.url, params={}) + self.assertEqual('application/json', response.content_type) + self.assertEqual(202, response.status_code) + + +class TestCredentialPolicyEnforcement(api_base.FunctionalTest): + + def _common_policy_check(self, rule, func, *arg, **kwarg): + self.policy.set_rules({rule: "project_id:non_fake"}) + response = func(*arg, **kwarg) + self.assertEqual(403, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue( + "Policy doesn't allow %s to be performed." % rule, + response.json['errors'][0]['detail']) + + def test_policy_disallow_rotate(self): + cluster = obj_utils.create_test_cluster(self.context) + self._common_policy_check( + "credential:rotate", self.patch_json, + '/credentials/%s' % cluster.uuid, + params={}, + expect_errors=True, + headers={ + 'OpenStack-API-Version': 'container-infra latest', + "X-Roles": "member" + } + ) diff --git a/releasenotes/notes/credential-api-be55ad07a3f2ae22.yaml b/releasenotes/notes/credential-api-be55ad07a3f2ae22.yaml new file mode 100644 index 0000000000..74d07cf5e3 --- /dev/null +++ b/releasenotes/notes/credential-api-be55ad07a3f2ae22.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + This release introduces the 'credentials' endpoint to Magnum API which + initially supports rotation of cluster credentials. The definition of + "credential" and the implementation to perform the rotation, is dictated by + the underlying driver.