From 16a2473b82565c5c2258f3a3b2674fca28bb8070 Mon Sep 17 00:00:00 2001 From: Tzu-Mainn Chen <tzumainn@redhat.com> Date: Wed, 12 Feb 2020 16:00:11 +0000 Subject: [PATCH] Add separate policies for updating node instance_info and extra In order to provision a node using standalone Ironic, a user must be able to update a few additional node attributes. However, we would not want a lessee user to be able to update every node attribute. This change allows an Ironic administrator to provide policy-based access to updating instance_info and extra. Change-Id: I43c22027116da1e057972dbe853403c16e965fc9 Story: #2006506 Task: #38748 --- ironic/api/controllers/v1/node.py | 20 ++- ironic/api/controllers/v1/utils.py | 24 +++ ironic/common/policy.py | 10 ++ .../unit/api/controllers/v1/test_expose.py | 2 + .../unit/api/controllers/v1/test_node.py | 158 ++++++++++++++++++ .../unit/api/controllers/v1/test_utils.py | 62 +++++++ ...-info-extra-policies-862b2a70b941cf39.yaml | 8 + 7 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 releasenotes/notes/node-update-instance-info-extra-policies-862b2a70b941cf39.yaml diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py index 582ed0a6d4..905795bc8b 100644 --- a/ironic/api/controllers/v1/node.py +++ b/ironic/api/controllers/v1/node.py @@ -2111,8 +2111,24 @@ class NodesController(rest.RestController): self._validate_patch(patch, reset_interfaces) context = api.request.context - rpc_node = api_utils.check_node_policy_and_retrieve( - 'baremetal:node:update', node_ident, with_suffix=True) + + # deal with attribute-specific policy rules + policy_checks = [] + generic_update = False + for p in patch: + if p['path'].startswith('/instance_info'): + policy_checks.append('baremetal:node:update_instance_info') + elif p['path'].startswith('/extra'): + policy_checks.append('baremetal:node:update_extra') + else: + generic_update = True + + # always do at least one check + if generic_update or policy_checks == []: + policy_checks.append('baremetal:node:update') + + rpc_node = api_utils.check_multiple_node_policies_and_retrieve( + policy_checks, node_ident, with_suffix=True) remove_inst_uuid_patch = [{'op': 'remove', 'path': '/instance_uuid'}] if rpc_node.maintenance and patch == remove_inst_uuid_patch: diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index 3c012ff42c..5c3349b0f5 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -1235,6 +1235,30 @@ def check_allocation_policy_and_retrieve(policy_name, allocation_ident): return rpc_allocation +def check_multiple_node_policies_and_retrieve(policy_names, + node_ident, + with_suffix=False): + """Check if the specified policies authorize this request on a node. + + :param: policy_names: List of policy names to check. + :param: node_ident: the UUID or logical name of a node. + :param: with_suffix: whether the RPC node should include the suffix + + :raises: HTTPForbidden if the policy forbids access. + :raises: NodeNotFound if the node is not found. + :return: RPC node identified by node_ident + """ + rpc_node = None + for policy_name in policy_names: + if rpc_node is None: + rpc_node = check_node_policy_and_retrieve(policy_names[0], + node_ident, + with_suffix) + else: + check_owner_policy('node', policy_name, rpc_node['owner']) + return rpc_node + + def check_list_policy(object_type, owner=None): """Check if the list policy authorizes this request on an object. diff --git a/ironic/common/policy.py b/ironic/common/policy.py index 16243f0f1c..828fbef739 100644 --- a/ironic/common/policy.py +++ b/ironic/common/policy.py @@ -104,6 +104,16 @@ node_policies = [ 'rule:is_admin', 'Update Node records', [{'path': '/nodes/{node_ident}', 'method': 'PATCH'}]), + policy.DocumentedRuleDefault( + 'baremetal:node:update_extra', + 'rule:baremetal:node:update', + 'Update Node extra field', + [{'path': '/nodes/{node_ident}', 'method': 'PATCH'}]), + policy.DocumentedRuleDefault( + 'baremetal:node:update_instance_info', + 'rule:baremetal:node:update', + 'Update Node instance_info field', + [{'path': '/nodes/{node_ident}', 'method': 'PATCH'}]), policy.DocumentedRuleDefault( 'baremetal:node:update_owner_provisioned', 'rule:is_admin', diff --git a/ironic/tests/unit/api/controllers/v1/test_expose.py b/ironic/tests/unit/api/controllers/v1/test_expose.py index 0f2323d78d..c2370fc612 100644 --- a/ironic/tests/unit/api/controllers/v1/test_expose.py +++ b/ironic/tests/unit/api/controllers/v1/test_expose.py @@ -53,6 +53,8 @@ class TestExposedAPIMethodsCheckPolicy(test_base.TestCase): self.assertTrue( ('api_utils.check_node_policy_and_retrieve' in src) or ('api_utils.check_list_policy' in src) or + ('api_utils.check_multiple_node_policies_and_retrieve' in + src) or ('self._get_node_and_topic' in src) or ('api_utils.check_port_policy_and_retrieve' in src) or ('api_utils.check_port_list_policy' in src) or diff --git a/ironic/tests/unit/api/controllers/v1/test_node.py b/ironic/tests/unit/api/controllers/v1/test_node.py index db571ffa52..429fab1b85 100644 --- a/ironic/tests/unit/api/controllers/v1/test_node.py +++ b/ironic/tests/unit/api/controllers/v1/test_node.py @@ -3400,6 +3400,164 @@ class TestPatch(test_api_base.BaseApiTest): self.assertEqual('application/json', response.content_type) self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code) + @mock.patch.object(api_utils, 'check_multiple_node_policies_and_retrieve') + def test_patch_policy_update(self, mock_cmnpar): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid()) + mock_cmnpar.return_value = node + self.mock_update_node.return_value = node + headers = {api_base.Version.string: str(api_v1.max_version())} + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/description', + 'value': 'foo', + 'op': 'replace'}], + headers=headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + mock_cmnpar.assert_called_once_with( + ['baremetal:node:update'], node.uuid, with_suffix=True) + + @mock.patch.object(api_utils, 'check_multiple_node_policies_and_retrieve') + def test_patch_policy_update_none(self, mock_cmnpar): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid()) + mock_cmnpar.return_value = node + self.mock_update_node.return_value = node + headers = {api_base.Version.string: str(api_v1.max_version())} + response = self.patch_json('/nodes/%s' % node.uuid, + [], + headers=headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + mock_cmnpar.assert_called_once_with( + ['baremetal:node:update'], node.uuid, with_suffix=True) + + @mock.patch.object(api_utils, 'check_multiple_node_policies_and_retrieve') + def test_patch_policy_update_extra(self, mock_cmnpar): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid()) + mock_cmnpar.return_value = node + self.mock_update_node.return_value = node + headers = {api_base.Version.string: str(api_v1.max_version())} + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/extra/foo', + 'value': 'bar', + 'op': 'add'}], + headers=headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + mock_cmnpar.assert_called_once_with( + ['baremetal:node:update_extra'], node.uuid, with_suffix=True) + + @mock.patch.object(api_utils, 'check_multiple_node_policies_and_retrieve') + def test_patch_policy_update_instance_info(self, mock_cmnpar): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid()) + mock_cmnpar.return_value = node + self.mock_update_node.return_value = node + headers = {api_base.Version.string: str(api_v1.max_version())} + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/instance_info/foo', + 'value': 'bar', + 'op': 'add'}], + headers=headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + mock_cmnpar.assert_called_once_with( + ['baremetal:node:update_instance_info'], + node.uuid, with_suffix=True) + + @mock.patch.object(api_utils, 'check_multiple_node_policies_and_retrieve') + def test_patch_policy_update_generic_and_extra(self, mock_cmnpar): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid()) + mock_cmnpar.return_value = node + self.mock_update_node.return_value = node + headers = {api_base.Version.string: str(api_v1.max_version())} + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/description', + 'value': 'foo', + 'op': 'replace'}, + {'path': '/extra/foo', + 'value': 'bar', + 'op': 'add'}], + headers=headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + mock_cmnpar.assert_called_once_with( + ['baremetal:node:update_extra', 'baremetal:node:update'], + node.uuid, with_suffix=True) + + @mock.patch.object(api_utils, 'check_multiple_node_policies_and_retrieve') + def test_patch_policy_update_generic_and_instance_info(self, mock_cmnpar): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid()) + mock_cmnpar.return_value = node + self.mock_update_node.return_value = node + headers = {api_base.Version.string: str(api_v1.max_version())} + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/description', + 'value': 'foo', + 'op': 'replace'}, + {'path': '/instance_info/foo', + 'value': 'bar', + 'op': 'add'}], + headers=headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + mock_cmnpar.assert_called_once_with( + ['baremetal:node:update_instance_info', 'baremetal:node:update'], + node.uuid, with_suffix=True) + + @mock.patch.object(api_utils, 'check_multiple_node_policies_and_retrieve') + def test_patch_policy_update_extra_and_instance_info(self, mock_cmnpar): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid()) + mock_cmnpar.return_value = node + self.mock_update_node.return_value = node + headers = {api_base.Version.string: str(api_v1.max_version())} + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/extra/foo', + 'value': 'bar', + 'op': 'add'}, + {'path': '/instance_info/foo', + 'value': 'bar', + 'op': 'add'}], + headers=headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + mock_cmnpar.assert_called_once_with( + ['baremetal:node:update_extra', + 'baremetal:node:update_instance_info'], + node.uuid, with_suffix=True) + + @mock.patch.object(api_utils, 'check_multiple_node_policies_and_retrieve') + def test_patch_policy_update_generic_extra_instance_info( + self, mock_cmnpar): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid()) + mock_cmnpar.return_value = node + self.mock_update_node.return_value = node + headers = {api_base.Version.string: str(api_v1.max_version())} + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/description', + 'value': 'foo', + 'op': 'replace'}, + {'path': '/extra/foo', + 'value': 'bar', + 'op': 'add'}, + {'path': '/instance_info/foo', + 'value': 'bar', + 'op': 'add'}], + headers=headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + mock_cmnpar.assert_called_once_with( + ['baremetal:node:update_extra', + 'baremetal:node:update_instance_info', + 'baremetal:node:update'], + node.uuid, with_suffix=True) + def _create_node_locally(node): driver_factory.check_and_update_node_interfaces(node) diff --git a/ironic/tests/unit/api/controllers/v1/test_utils.py b/ironic/tests/unit/api/controllers/v1/test_utils.py index ab7c028273..077ddc3023 100644 --- a/ironic/tests/unit/api/controllers/v1/test_utils.py +++ b/ironic/tests/unit/api/controllers/v1/test_utils.py @@ -1016,6 +1016,68 @@ class TestCheckAllocationPolicyAndRetrieve(base.TestCase): ) +class TestCheckMultipleNodePoliciesAndRetrieve(base.TestCase): + def setUp(self): + super(TestCheckMultipleNodePoliciesAndRetrieve, self).setUp() + self.valid_node_uuid = uuidutils.generate_uuid() + self.node = test_api_utils.post_get_test_node() + self.node['owner'] = '12345' + + @mock.patch.object(utils, 'check_node_policy_and_retrieve') + @mock.patch.object(utils, 'check_owner_policy') + def test_check_multiple_node_policies_and_retrieve( + self, mock_cop, mock_cnpar + ): + mock_cnpar.return_value = self.node + mock_cop.return_value = True + + rpc_node = utils.check_multiple_node_policies_and_retrieve( + ['fake_policy_1', 'fake_policy_2'], self.valid_node_uuid + ) + mock_cnpar.assert_called_once_with('fake_policy_1', + self.valid_node_uuid, False) + mock_cop.assert_called_once_with( + 'node', 'fake_policy_2', '12345') + self.assertEqual(self.node, rpc_node) + + @mock.patch.object(utils, 'check_node_policy_and_retrieve') + @mock.patch.object(utils, 'check_owner_policy') + def test_check_multiple_node_policies_and_retrieve_first_fail( + self, mock_cop, mock_cnpar + ): + mock_cnpar.side_effect = exception.HTTPForbidden(resource='fake') + mock_cop.return_value = True + + self.assertRaises( + exception.HTTPForbidden, + utils.check_multiple_node_policies_and_retrieve, + ['fake_policy_1', 'fake_policy_2'], + self.valid_node_uuid + ) + mock_cnpar.assert_called_once_with('fake_policy_1', + self.valid_node_uuid, False) + mock_cop.assert_not_called() + + @mock.patch.object(utils, 'check_node_policy_and_retrieve') + @mock.patch.object(utils, 'check_owner_policy') + def test_check_node_policy_and_retrieve_no_node( + self, mock_cop, mock_cnpar + ): + mock_cnpar.return_value = self.node + mock_cop.side_effect = exception.HTTPForbidden(resource='fake') + + self.assertRaises( + exception.HTTPForbidden, + utils.check_multiple_node_policies_and_retrieve, + ['fake_policy_1', 'fake_policy_2'], + self.valid_node_uuid + ) + mock_cnpar.assert_called_once_with('fake_policy_1', + self.valid_node_uuid, False) + mock_cop.assert_called_once_with( + 'node', 'fake_policy_2', '12345') + + class TestCheckListPolicy(base.TestCase): @mock.patch.object(api, 'request', spec_set=["context", "version"]) @mock.patch.object(policy, 'authorize', spec=True) diff --git a/releasenotes/notes/node-update-instance-info-extra-policies-862b2a70b941cf39.yaml b/releasenotes/notes/node-update-instance-info-extra-policies-862b2a70b941cf39.yaml new file mode 100644 index 0000000000..ce98552398 --- /dev/null +++ b/releasenotes/notes/node-update-instance-info-extra-policies-862b2a70b941cf39.yaml @@ -0,0 +1,8 @@ +--- +features: + - | + Adds ``baremetal:node:update_extra`` and ``baremetal:node:instance_info`` + policies to allow finer-grained policy control over node updates. In order + to use standalone Ironic to provision a node, a user must be able to + update these attributes, and a lessee should not be able to update all + node attributes.