diff --git a/changes/1668.feature.2.rst b/changes/1668.feature.2.rst new file mode 100644 index 00000000..b044c786 --- /dev/null +++ b/changes/1668.feature.2.rst @@ -0,0 +1,3 @@ +Added zhmcclient mock support for MFA Server Definitions with a new +'zhmcclient_mock.FakedMfaServerDefinition' class (and a corresponding manager +class). diff --git a/changes/1668.feature.rst b/changes/1668.feature.rst new file mode 100644 index 00000000..5d67a73d --- /dev/null +++ b/changes/1668.feature.rst @@ -0,0 +1,2 @@ +Added support for MFA Server Definitions with a new 'zhmcclient.MfaServerDefinition' +resource class (and corresponding manager class). diff --git a/docs/appendix.rst b/docs/appendix.rst index 42e14786..8754d24c 100644 --- a/docs/appendix.rst +++ b/docs/appendix.rst @@ -396,6 +396,10 @@ Resources scoped to the HMC Metrics Context A user-created definition of metrics that can be retrieved. + MFA Server Definition + The information in an HMC about an MFA server that may be used for + HMC user authorization purposes. + Password Rule A rule which HMC users need to follow when creating a HMC logon password. diff --git a/docs/resources.rst b/docs/resources.rst index 6c5ec429..0c0bd8bc 100644 --- a/docs/resources.rst +++ b/docs/resources.rst @@ -558,6 +558,26 @@ LDAP Server Definition :special-members: __str__ +.. _`MFA Server Definition`: + +MFA Server Definition +--------------------- + +.. automodule:: zhmcclient._mfa_server_definition + +.. autoclass:: zhmcclient.MfaServerDefinitionManager + :members: + :autosummary: + :autosummary-inherited-members: + :special-members: __str__ + +.. autoclass:: zhmcclient.MfaServerDefinition + :members: + :autosummary: + :autosummary-inherited-members: + :special-members: __str__ + + .. _`Certificates`: Certificates diff --git a/tests/end2end/test_mfa_server_definition.py b/tests/end2end/test_mfa_server_definition.py new file mode 100644 index 00000000..df0ba223 --- /dev/null +++ b/tests/end2end/test_mfa_server_definition.py @@ -0,0 +1,209 @@ +# Copyright 2025 IBM Corp. 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. + +""" +End2end tests for MFA server definitions (on CPCs in DPM mode). + +These tests do not change any existing MFA server definitions, but create, +modify and delete test MFA server definitions. +""" + + +import warnings +import pytest +from requests.packages import urllib3 + +import zhmcclient +# pylint: disable=line-too-long,unused-import +from zhmcclient.testutils import hmc_definition, hmc_session # noqa: F401, E501 +# pylint: enable=line-too-long,unused-import + +from .utils import skip_warn, pick_test_resources, TEST_PREFIX, \ + runtest_find_list, runtest_get_properties + +urllib3.disable_warnings() + +# Properties in minimalistic MFAServerDefinition objects (e.g. find_by_name()) +MFASRVDEF_MINIMAL_PROPS = ['element-uri', 'name'] + +# Properties in MFAServerDefinition objects returned by list() without full +# props +MFASRVDEF_LIST_PROPS = ['element-uri', 'name'] + +# Properties whose values can change between retrievals of MFAServerDefinition +# objects +MFASRVDEF_VOLATILE_PROPS = [] + + +def test_mfasrvdef_find_list(hmc_session): # noqa: F811 + # pylint: disable=redefined-outer-name + """ + Test list(), find(), findall(). + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + api_version = client.query_api_version() + hmc_version = api_version['hmc-version'] + hmc_version_info = tuple(map(int, hmc_version.split('.'))) + if hmc_version_info < (2, 15, 0): + skip_warn(f"HMC {hd.host} of version {hmc_version} does not yet " + "support MFA server definitions") + + # Pick the MFA server definitions to test with + mfasrvdef_list = console.mfa_server_definitions.list() + if not mfasrvdef_list: + skip_warn(f"No MFA server definitions defined on HMC {hd.host}") + mfasrvdef_list = pick_test_resources(mfasrvdef_list) + + for mfasrvdef in mfasrvdef_list: + print(f"Testing with MFA server definition {mfasrvdef.name!r}") + runtest_find_list( + hmc_session, console.mfa_server_definitions, mfasrvdef.name, + 'name', 'element-uri', MFASRVDEF_VOLATILE_PROPS, + MFASRVDEF_MINIMAL_PROPS, MFASRVDEF_LIST_PROPS) + + +def test_mfasrvdef_property(hmc_session): # noqa: F811 + # pylint: disable=redefined-outer-name + """ + Test property related methods + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + api_version = client.query_api_version() + hmc_version = api_version['hmc-version'] + hmc_version_info = tuple(map(int, hmc_version.split('.'))) + if hmc_version_info < (2, 15, 0): + skip_warn(f"HMC {hd.host} of version {hmc_version} does not yet " + "support MFA server definitions") + + # Pick the MFA server definitions to test with + mfasrvdef_list = console.mfa_server_definitions.list() + if not mfasrvdef_list: + skip_warn(f"No MFA server definitions defined on HMC {hd.host}") + mfasrvdef_list = pick_test_resources(mfasrvdef_list) + + for mfasrvdef in mfasrvdef_list: + print(f"Testing with MFA server definition {mfasrvdef.name!r}") + + # Select a property that is not returned by list() + non_list_prop = 'description' + + runtest_get_properties(mfasrvdef.manager, non_list_prop) + + +def test_mfasrvdef_crud(hmc_session): # noqa: F811 + # pylint: disable=redefined-outer-name + """ + Test create, read, update and delete a MFA server definition. + """ + client = zhmcclient.Client(hmc_session) + console = client.consoles.console + hd = hmc_session.hmc_definition + + api_version = client.query_api_version() + hmc_version = api_version['hmc-version'] + hmc_version_info = tuple(map(int, hmc_version.split('.'))) + if hmc_version_info < (2, 15, 0): + skip_warn(f"HMC {hd.host} of version {hmc_version} does not yet " + "support MFA server definitions") + + mfasrvdef_name = TEST_PREFIX + ' test_mfasrvdef_crud mfasrvdef1' + mfasrvdef_name_new = mfasrvdef_name + ' new' + + # Ensure a clean starting point for this test + try: + mfasrvdef = console.mfa_server_definitions.find( + name=mfasrvdef_name) + except zhmcclient.NotFound: + pass + else: + warnings.warn( + "Deleting test MFA server definition from previous run: " + f"{mfasrvdef_name!r}", UserWarning) + mfasrvdef.delete() + + mfasrvdef = None + try: + + # Test creating the MFA server definition + + mfasrvdef_input_props = { + 'name': mfasrvdef_name, + 'description': 'Test MFA server def for zhmcclient end2end tests', + 'hostname-ipaddr': '10.11.12.13', + } + mfasrvdef_auto_props = { + 'port': 6789, + 'replication-overwrite-possible': False, + } + + # The code to be tested + try: + mfasrvdef = console.mfa_server_definitions.create( + mfasrvdef_input_props) + except zhmcclient.HTTPError as exc: + if exc.http_status == 403 and exc.reason == 1: + skip_warn(f"HMC userid {hd.userid!r} is not authorized for " + "task 'Manage Multi-factor Authentication' on HMC " + f"{hd.host}") + else: + raise + + for pn, exp_value in mfasrvdef_input_props.items(): + assert mfasrvdef.properties[pn] == exp_value, \ + f"Unexpected value for property {pn!r}" + mfasrvdef.pull_full_properties() + for pn, exp_value in mfasrvdef_input_props.items(): + assert mfasrvdef.properties[pn] == exp_value, \ + f"Unexpected value for property {pn!r}" + for pn, exp_value in mfasrvdef_auto_props.items(): + assert mfasrvdef.properties[pn] == exp_value, \ + f"Unexpected value for property {pn!r}" + + # Test updating a property of the MFA server definition + + new_desc = "Updated MFA server definition description." + + # The code to be tested + mfasrvdef.update_properties(dict(description=new_desc)) + + assert mfasrvdef.properties['description'] == new_desc + mfasrvdef.pull_full_properties() + assert mfasrvdef.properties['description'] == new_desc + + # Test that MFA server definitions can be renamed + + # The code to be tested + mfasrvdef.update_properties(dict(name=mfasrvdef_name_new)) + + with pytest.raises(zhmcclient.NotFound): + console.mfa_server_definitions.find(name=mfasrvdef_name) + + finally: + if mfasrvdef: + # Cleanup and test deleting the MFA server definition + + # The code to be tested + mfasrvdef.delete() + + with pytest.raises(zhmcclient.NotFound): + console.mfa_server_definitions.find(name=mfasrvdef_name) + + with pytest.raises(zhmcclient.NotFound): + console.mfa_server_definitions.find(name=mfasrvdef_name_new) diff --git a/tests/unit/zhmcclient/test_mfa_server_definition.py b/tests/unit/zhmcclient/test_mfa_server_definition.py new file mode 100644 index 00000000..2a09a45d --- /dev/null +++ b/tests/unit/zhmcclient/test_mfa_server_definition.py @@ -0,0 +1,341 @@ +# Copyright 2025 IBM Corp. 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. + +""" +Unit tests for _mfa_server_definition module. +""" + +import re +import copy +import logging +import pytest + +from zhmcclient import Client, HTTPError, NotFound, MfaServerDefinition +from zhmcclient_mock import FakedSession +from tests.common.utils import assert_resources + + +class TestMfaServerDefinition: + """All tests for the MfaServerDefinition and MfaServerDefinitionManager + classes.""" + + def setup_method(self): + """ + Setup that is called by pytest before each test method. + + Set up a faked session, and add a faked Console without any + child resources. + """ + # pylint: disable=attribute-defined-outside-init + + self.session = FakedSession('fake-host', 'fake-hmc', '2.13.1', '1.8') + self.client = Client(self.session) + + self.faked_console = self.session.hmc.consoles.add({ + 'object-id': None, + # object-uri will be automatically set + 'parent': None, + 'class': 'console', + 'name': 'fake-console1', + 'description': 'Console #1', + }) + self.console = self.client.consoles.find(name=self.faked_console.name) + + def add_mfa_srv_def(self, name): + """ + Add a faked MFAServerDefinition object to the faked Console + and return it. + """ + + faked_mfa_srv_def = self.faked_console.mfa_server_definitions.add({ + 'element-id': f'oid-{name}', + # element-uri will be automatically set + 'parent': '/api/console', + 'class': 'mfa-server-definition', + 'name': name, + 'description': f'MFA Server Definition {name}', + 'hostname-ipaddr': f'host-{name}', + }) + return faked_mfa_srv_def + + def test_mfa_srv_def_manager_repr(self): + """Test MfaServerDefinitionManager.__repr__().""" + + mfa_srv_def_mgr = self.console.mfa_server_definitions + + # Execute the code to be tested + repr_str = repr(mfa_srv_def_mgr) + + repr_str = repr_str.replace('\n', '\\n') + # We check just the begin of the string: + assert re.match( + rf'^{mfa_srv_def_mgr.__class__.__name__}\s+at\s+' + rf'0x{id(mfa_srv_def_mgr):08x}\s+\(\\n.*', + repr_str) + + def test_mfa_srv_def_manager_initial_attrs(self): + """Test initial attributes of MfaServerDefinitionManager.""" + + mfa_srv_def_mgr = self.console.mfa_server_definitions + + # Verify all public properties of the manager object + assert mfa_srv_def_mgr.resource_class == MfaServerDefinition + assert mfa_srv_def_mgr.class_name == 'mfa-server-definition' + assert mfa_srv_def_mgr.session is self.session + assert mfa_srv_def_mgr.parent is self.console + assert mfa_srv_def_mgr.console is self.console + + @pytest.mark.parametrize( + "full_properties_kwargs, prop_names", [ + (dict(full_properties=False), + ['element-uri', 'name']), + (dict(full_properties=True), + ['element-uri', 'name', 'description']), + ({}, # test default for full_properties (False) + ['element-uri', 'name']), + ] + ) + @pytest.mark.parametrize( + "filter_args, exp_names", [ + (None, + ['a', 'b']), + ({}, + ['a', 'b']), + ({'name': 'a'}, + ['a']), + ({'name': 'A'}, # MFA user definitions have case-insensitive names + ['a']), + ] + ) + def test_mfa_srv_def_manager_list( + self, filter_args, exp_names, full_properties_kwargs, prop_names): + """Test MfaServerDefinitionManager.list().""" + + faked_mfa_srv_def1 = self.add_mfa_srv_def(name='a') + faked_mfa_srv_def2 = self.add_mfa_srv_def(name='b') + faked_mfa_srv_defs = [faked_mfa_srv_def1, faked_mfa_srv_def2] + exp_faked_mfa_srv_defs = [u for u in faked_mfa_srv_defs + if u.name in exp_names] + mfa_srv_def_mgr = self.console.mfa_server_definitions + + # Execute the code to be tested + mfa_srv_defs = mfa_srv_def_mgr.list(filter_args=filter_args, + **full_properties_kwargs) + + assert_resources(mfa_srv_defs, exp_faked_mfa_srv_defs, prop_names) + + @pytest.mark.parametrize( + "input_props, exp_prop_names, exp_exc", [ + ({}, # props missing + None, + HTTPError({'http-status': 400, 'reason': 5})), + ({'description': 'fake description X'}, # props missing + None, + HTTPError({'http-status': 400, 'reason': 5})), + ({'description': 'fake description X', + 'name': 'a', + 'hostname-ipaddr': '10.11.12.13'}, + ['element-uri', 'name', 'description'], + None), + ({'name': 'a', + 'hostname-ipaddr': '10.11.12.13', + 'port': 1234}, + ['element-uri', 'name', 'port'], + None), + ] + ) + def test_mfa_srv_def_manager_create( + self, caplog, input_props, exp_prop_names, exp_exc): + """Test MfaServerDefinitionManager.create().""" + + logger_name = "zhmcclient.api" + caplog.set_level(logging.DEBUG, logger=logger_name) + + mfa_srv_def_mgr = self.console.mfa_server_definitions + + if exp_exc is not None: + + with pytest.raises(exp_exc.__class__) as exc_info: + + # Execute the code to be tested + mfa_srv_def_mgr.create(properties=input_props) + + exc = exc_info.value + if isinstance(exp_exc, HTTPError): + assert exc.http_status == exp_exc.http_status + assert exc.reason == exp_exc.reason + + else: + + # Execute the code to be tested. + mfa_srv_def = mfa_srv_def_mgr.create(properties=input_props) + + # Check the resource for consistency within itself + assert isinstance(mfa_srv_def, MfaServerDefinition) + mfa_srv_def_name = mfa_srv_def.name + exp_mfa_srv_def_name = mfa_srv_def.properties['name'] + assert mfa_srv_def_name == exp_mfa_srv_def_name + mfa_srv_def_uri = mfa_srv_def.uri + exp_mfa_srv_def_uri = mfa_srv_def.properties['element-uri'] + assert mfa_srv_def_uri == exp_mfa_srv_def_uri + + # Check the properties against the expected names and values + for prop_name in exp_prop_names: + assert prop_name in mfa_srv_def.properties + if prop_name in input_props: + value = mfa_srv_def.properties[prop_name] + exp_value = input_props[prop_name] + assert value == exp_value + + def test_mfa_srv_def_repr(self): + """Test MfaServerDefinition.__repr__().""" + + faked_mfa_srv_def1 = self.add_mfa_srv_def(name='a') + mfa_srv_def1 = self.console.mfa_server_definitions.find( + name=faked_mfa_srv_def1.name) + + # Execute the code to be tested + repr_str = repr(mfa_srv_def1) + + repr_str = repr_str.replace('\n', '\\n') + # We check just the begin of the string: + assert re.match( + rf'^{mfa_srv_def1.__class__.__name__}\s+at\s+' + rf'0x{id(mfa_srv_def1):08x}\s+\(\\n.*', + repr_str) + + @pytest.mark.parametrize( + "input_props, exp_exc", [ + ({'name': 'a'}, + None), + ({'name': 'b'}, + None), + ] + ) + def test_mfa_srv_def_delete(self, input_props, exp_exc): + """Test MfaServerDefinition.delete().""" + + faked_mfa_srv_def = self.add_mfa_srv_def(name=input_props['name']) + + mfa_srv_def_mgr = self.console.mfa_server_definitions + mfa_srv_def = mfa_srv_def_mgr.find(name=faked_mfa_srv_def.name) + + if exp_exc is not None: + + with pytest.raises(exp_exc.__class__) as exc_info: + + # Execute the code to be tested + mfa_srv_def.delete() + + exc = exc_info.value + if isinstance(exp_exc, HTTPError): + assert exc.http_status == exp_exc.http_status + assert exc.reason == exp_exc.reason + + # Check that the MFA Server Definition still exists + mfa_srv_def_mgr.find(name=faked_mfa_srv_def.name) + + else: + + # Execute the code to be tested. + mfa_srv_def.delete() + + # Check that the MFA Server Definition no longer exists + with pytest.raises(NotFound) as exc_info: + mfa_srv_def_mgr.find(name=faked_mfa_srv_def.name) + + def test_mfa_delete_create_same(self): + """Test MfaServerDefinition.delete() followed by create() with same + name.""" + + mfa_srv_def_name = 'faked_a' + + # Add the MFA Server Definition to be tested + self.add_mfa_srv_def(name=mfa_srv_def_name) + + # Input properties for a MFA Server Definition with the same name + sn_mfa_srv_def_props = { + 'name': mfa_srv_def_name, + 'description': 'MFA Server Definition with same name', + 'hostname-ipaddr': '10.11.12.13', + } + + mfa_srv_def_mgr = self.console.mfa_server_definitions + mfa_srv_def = mfa_srv_def_mgr.find(name=mfa_srv_def_name) + + # Execute the deletion code to be tested + mfa_srv_def.delete() + + # Check that the MFA Server Definition no longer exists + with pytest.raises(NotFound): + mfa_srv_def_mgr.find(name=mfa_srv_def_name) + + # Execute the creation code to be tested. + mfa_srv_def_mgr.create(sn_mfa_srv_def_props) + + # Check that the MFA Server Definition exists again under that name + sn_mfa_srv_def = mfa_srv_def_mgr.find(name=mfa_srv_def_name) + description = sn_mfa_srv_def.get_property('description') + assert description == sn_mfa_srv_def_props['description'] + + @pytest.mark.parametrize( + "input_props", [ + {}, + {'description': 'New MFA Server Definition description'}, + {'port': 1234}, + ] + ) + def test_mfa_srv_def_update_properties(self, caplog, input_props): + """Test MfaServerDefinition.update_properties().""" + + logger_name = "zhmcclient.api" + caplog.set_level(logging.DEBUG, logger=logger_name) + + mfa_srv_def_name = 'faked_a' + + # Add the MFA Server Definition to be tested + self.add_mfa_srv_def(name=mfa_srv_def_name) + + mfa_srv_def_mgr = self.console.mfa_server_definitions + mfa_srv_def = mfa_srv_def_mgr.find(name=mfa_srv_def_name) + + mfa_srv_def.pull_full_properties() + saved_properties = copy.deepcopy(mfa_srv_def.properties) + + # Execute the code to be tested + mfa_srv_def.update_properties(properties=input_props) + + # Verify that the resource object already reflects the property + # updates. + for prop_name in saved_properties: + if prop_name in input_props: + exp_prop_value = input_props[prop_name] + else: + exp_prop_value = saved_properties[prop_name] + assert prop_name in mfa_srv_def.properties + prop_value = mfa_srv_def.properties[prop_name] + assert prop_value == exp_prop_value, \ + f"Unexpected value for property {prop_name!r}" + + # Refresh the resource object and verify that the resource object + # still reflects the property updates. + mfa_srv_def.pull_full_properties() + for prop_name in saved_properties: + if prop_name in input_props: + exp_prop_value = input_props[prop_name] + else: + exp_prop_value = saved_properties[prop_name] + assert prop_name in mfa_srv_def.properties + prop_value = mfa_srv_def.properties[prop_name] + assert prop_value == exp_prop_value diff --git a/tests/unit/zhmcclient_mock/test_urihandler.py b/tests/unit/zhmcclient_mock/test_urihandler.py index 7cc85cfc..1325c0ba 100644 --- a/tests/unit/zhmcclient_mock/test_urihandler.py +++ b/tests/unit/zhmcclient_mock/test_urihandler.py @@ -48,6 +48,7 @@ UserPatternsHandler, UserPatternHandler, \ PasswordRulesHandler, PasswordRuleHandler, \ LdapServerDefinitionsHandler, LdapServerDefinitionHandler, \ + MfaServerDefinitionsHandler, MfaServerDefinitionHandler, \ CpcsHandler, CpcHandler, CpcSetPowerSaveHandler, \ CpcSetPowerCappingHandler, CpcGetEnergyManagementDataHandler, \ CpcStartHandler, CpcStopHandler, \ @@ -793,6 +794,16 @@ def standard_test_hmc(): }, }, ], + 'mfa_server_definitions': [ + { + 'properties': { + 'element-id': 'fake-mfa-srv-def-oid-1', + 'name': 'fake_mfa_srv_def_name_1', + 'description': 'MFA Srv Def #1', + 'hostname-ipaddr': '10.11.12.13', + }, + }, + ], 'storage_groups': [ { 'properties': { @@ -3469,6 +3480,195 @@ def test_lsd_delete_verify(self): self.urihandler.get(self.hmc, new_ldap_srv_def_uri, True) +class TestMfaServerDefinitionHandlers: + """ + All tests for classes MfaServerDefinitionsHandler and + MfaServerDefinitionHandler. + """ + + def setup_method(self): + """ + Called by pytest before each test method. + + Creates a Faked HMC with standard resources, and with + MfaServerDefinitionsHandler and MfaServerDefinitionHandler. + """ + self.hmc, self.hmc_resources = standard_test_hmc() + self.uris = ( + (r'/api/console/mfa-server-definitions(?:\?(.*))?', + MfaServerDefinitionsHandler), + (r'/api/console/mfa-server-definitions/([^/]+)', + MfaServerDefinitionHandler), + ) + self.urihandler = UriHandler(self.uris) + + def test_mfa_list(self): + """ + Test GET MFA server definitions (list). + """ + + # the function to be tested: + mfa_srv_defs = self.urihandler.get( + self.hmc, '/api/console/mfa-server-definitions', True) + + exp_mfa_srv_defs = { # properties reduced to those returned by List + 'mfa-server-definitions': [ + { + 'element-uri': + '/api/console/mfa-server-definitions/' + 'fake-mfa-srv-def-oid-1', + 'name': 'fake_mfa_srv_def_name_1', + }, + ] + } + assert mfa_srv_defs == exp_mfa_srv_defs + + def test_mfa_list_err_no_console(self): + """ + Test GET MFA server definitions (list) when console does not exist + in the faked HMC. + """ + + # Remove the faked Console object + self.hmc.consoles.remove(None) + + with pytest.raises(InvalidResourceError) as exc_info: + + # the function to be tested: + self.urihandler.get( + self.hmc, '/api/console/mfa-server-definitions', True) + + exc = exc_info.value + assert exc.reason == 1 + + def test_mfa_get(self): + """ + Test GET MFA server definition. + """ + + # the function to be tested: + mfa_srv_def1 = self.urihandler.get( + self.hmc, + '/api/console/mfa-server-definitions/fake-mfa-srv-def-oid-1', + True) + + exp_mfa_srv_def1 = { # properties reduced to those in std test HMC + 'element-id': 'fake-mfa-srv-def-oid-1', + 'element-uri': + '/api/console/mfa-server-definitions/' + 'fake-mfa-srv-def-oid-1', + 'class': 'mfa-server-definition', + 'parent': '/api/console', + 'name': 'fake_mfa_srv_def_name_1', + 'description': 'MFA Srv Def #1', + 'hostname-ipaddr': '10.11.12.13', + 'port': 6789, + 'replication-overwrite-possible': False, + } + assert mfa_srv_def1 == exp_mfa_srv_def1 + + def test_mfa_create_verify(self): + """ + Test POST MFA server definitions (create). + """ + + new_mfa_srv_def_input = { + 'name': 'mfa_srv_def_X', + 'description': 'MFA Srv Def #X', + 'hostname-ipaddr': '10.11.12.13', + } + + # the function to be tested: + resp = self.urihandler.post( + self.hmc, '/api/console/mfa-server-definitions', + new_mfa_srv_def_input, True, True) + + assert len(resp) == 1 + assert 'element-uri' in resp + new_mfa_srv_def_uri = resp['element-uri'] + + # the function to be tested: + new_mfa_srv_def = self.urihandler.get( + self.hmc, new_mfa_srv_def_uri, True) + + new_name = new_mfa_srv_def['name'] + input_name = new_mfa_srv_def_input['name'] + assert new_name == input_name + + def test_mfa_create_err_no_console(self): + """ + Test POST MFA server definitions (create) when console does not exist + in the faked HMC. + """ + + # Remove the faked Console object + self.hmc.consoles.remove(None) + + new_mfa_srv_def_input = { + 'name': 'mfa_srv_def_X', + 'description': 'MFA Srv Def #X', + } + + with pytest.raises(InvalidResourceError) as exc_info: + + # the function to be tested: + self.urihandler.post( + self.hmc, '/api/console/mfa-server-definitions', + new_mfa_srv_def_input, True, True) + + exc = exc_info.value + assert exc.reason == 1 + + def test_mfa_update_verify(self): + """ + Test POST MFA server definition (update). + """ + + update_mfa_srv_def1 = { + 'description': 'updated MFA Srv Def #1', + } + + # the function to be tested: + self.urihandler.post( + self.hmc, + '/api/console/mfa-server-definitions/fake-mfa-srv-def-oid-1', + update_mfa_srv_def1, True, True) + + mfa_srv_def1 = self.urihandler.get( + self.hmc, + '/api/console/mfa-server-definitions/fake-mfa-srv-def-oid-1', + True) + assert mfa_srv_def1['description'] == 'updated MFA Srv Def #1' + + def test_mfa_delete_verify(self): + """ + Test DELETE MFA server definition. + """ + + new_mfa_srv_def_input = { + 'name': 'mfa_srv_def_X', + 'description': 'MFA Srv Def #X', + 'hostname-ipaddr': '10.11.12.13', + } + + # Create the MFA Srv Def + resp = self.urihandler.post( + self.hmc, '/api/console/mfa-server-definitions', + new_mfa_srv_def_input, True, True) + + new_mfa_srv_def_uri = resp['element-uri'] + + # Verify that it exists + self.urihandler.get(self.hmc, new_mfa_srv_def_uri, True) + + # the function to be tested: + self.urihandler.delete(self.hmc, new_mfa_srv_def_uri, True) + + # Verify that it has been deleted + with pytest.raises(InvalidResourceError): + self.urihandler.get(self.hmc, new_mfa_srv_def_uri, True) + + class TestCpcHandlers: """ All tests for classes CpcsHandler and CpcHandler. diff --git a/zhmcclient/__init__.py b/zhmcclient/__init__.py index 4ea8591a..7da549be 100644 --- a/zhmcclient/__init__.py +++ b/zhmcclient/__init__.py @@ -51,6 +51,7 @@ from ._password_rule import * # noqa: F401 from ._task import * # noqa: F401 from ._ldap_server_definition import * # noqa: F401 +from ._mfa_server_definition import * # noqa: F401 from ._unmanaged_cpc import * # noqa: F401 from ._storage_group import * # noqa: F401 from ._storage_volume import * # noqa: F401 diff --git a/zhmcclient/_console.py b/zhmcclient/_console.py index 66c0cd65..6d52c000 100644 --- a/zhmcclient/_console.py +++ b/zhmcclient/_console.py @@ -35,6 +35,7 @@ from ._password_rule import PasswordRuleManager from ._task import TaskManager from ._ldap_server_definition import LdapServerDefinitionManager +from ._mfa_server_definition import MfaServerDefinitionManager from ._unmanaged_cpc import UnmanagedCpcManager from ._group import GroupManager from ._utils import get_features @@ -211,6 +212,7 @@ def __init__(self, manager, uri, name=None, properties=None): self._password_rules = None self._tasks = None self._ldap_server_definitions = None + self._mfa_server_definitions = None self._unmanaged_cpcs = None self._groups = None self._certificates = None @@ -305,6 +307,18 @@ def ldap_server_definitions(self): self._ldap_server_definitions = LdapServerDefinitionManager(self) return self._ldap_server_definitions + @property + def mfa_server_definitions(self): + """ + :class:`~zhmcclient.MfaServerDefinitionManager`: Access to the + :term:`MFA Server Definitions ` in this + Console. + """ + # We do here some lazy loading. + if not self._mfa_server_definitions: + self._mfa_server_definitions = MfaServerDefinitionManager(self) + return self._mfa_server_definitions + @property def unmanaged_cpcs(self): """ @@ -1403,6 +1417,7 @@ def dump(self): "password_rules": [...], "tasks": [...], "ldap_server_definitions": [...], + "mfa_server_definitions": [...], "unmanaged_cpcs": [...], "storage_groups": [...], } @@ -1434,6 +1449,9 @@ def dump(self): ldap_server_definitions = self.ldap_server_definitions.dump() if ldap_server_definitions: resource_dict['ldap_server_definitions'] = ldap_server_definitions + mfa_server_definitions = self.mfa_server_definitions.dump() + if mfa_server_definitions: + resource_dict['mfa_server_definitions'] = mfa_server_definitions storage_groups = self.storage_groups.dump() if storage_groups: resource_dict['storage_groups'] = storage_groups diff --git a/zhmcclient/_mfa_server_definition.py b/zhmcclient/_mfa_server_definition.py new file mode 100644 index 00000000..d9f8e23c --- /dev/null +++ b/zhmcclient/_mfa_server_definition.py @@ -0,0 +1,301 @@ +# Copyright 2025 IBM Corp. 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. + +""" +A :term:`MFA Server Definition` resource represents a definition that contains +information about an Multi-factor Authentication (MFA) server that may be used +for HMC user authentication purposes. +""" + + +import copy + +from ._manager import BaseManager +from ._resource import BaseResource +from ._logging import logged_api_call +from ._utils import RC_MFA_SERVER_DEFINITION + +__all__ = ['MfaServerDefinitionManager', 'MfaServerDefinition'] + + +class MfaServerDefinitionManager(BaseManager): + """ + Manager providing access to the :term:`MFA Server Definition` resources of + a HMC. + + Derived from :class:`~zhmcclient.BaseManager`; see there for common methods + and attributes. + + Objects of this class are not directly created by the user; they are + accessible via the following instance variable of a + :class:`~zhmcclient.Console` object: + + * :attr:`zhmcclient.Console.mfa_server_definitions` + + HMC/SE version requirements: + + * HMC version == 2.15.0 + """ + + def __init__(self, console): + # This function should not go into the docs. + # Parameters: + # console (:class:`~zhmcclient.Console`): + # Console object representing the HMC. + + # Resource properties that are supported as filter query parameters. + # If the support for a resource property changes within the set of HMC + # versions that support this type of resource, this list must be set up + # for the version of the HMC this session is connected to. + # Because this resource has case-insensitive names, this list must + # contain the name property. + query_props = [ + 'name', + ] + + super().__init__( + resource_class=MfaServerDefinition, + class_name=RC_MFA_SERVER_DEFINITION, + session=console.manager.session, + parent=console, + base_uri='/api/console/mfa-server-definitions', + oid_prop='element-id', + uri_prop='element-uri', + name_prop='name', + query_props=query_props, + case_insensitive_names=True) + + @property + def console(self): + """ + :class:`~zhmcclient.Console`: :term:`Console` defining the scope for + this manager. + """ + return self._parent + + @logged_api_call + def list(self, full_properties=False, filter_args=None): + """ + List the :term:`MFA Server Definition` resources representing the + definitions of MFA servers in this HMC. + + Any resource property may be specified in a filter argument. For + details about filter arguments, see :ref:`Filtering`. + + The listing of resources is handled in an optimized way: + + * If this manager is enabled for :ref:`auto-updating`, a locally + maintained resource list is used (which is automatically updated via + inventory notifications from the HMC) and the provided filter + arguments are applied. + + * Otherwise, if the filter arguments specify the resource name as a + single filter argument with a straight match string (i.e. without + regular expressions), an optimized lookup is performed based on a + locally maintained name-URI cache. + + * Otherwise, the HMC List operation is performed with the subset of the + provided filter arguments that can be handled on the HMC side and the + remaining filter arguments are applied on the client side on the list + result. + + HMC/SE version requirements: + + * HMC version == 2.15.0 + + Authorization requirements: + + * User-related-access permission to the MFA Server Definition objects + included in the result, or task permission to the "Manage Multi-factor + Authentication" task. + + Parameters: + + full_properties (bool): + Controls whether the full set of resource properties should be + retrieved, vs. only the short set as returned by the list + operation. + + filter_args (dict): + Filter arguments that narrow the list of returned resources to + those that match the specified filter arguments. For details, see + :ref:`Filtering`. + + `None` causes no filtering to happen, i.e. all resources are + returned. + + Returns: + + : A list of :class:`~zhmcclient.MfaServerDefinition` objects. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + """ + result_prop = 'mfa-server-definitions' + list_uri = f'{self.console.uri}/mfa-server-definitions' + return self._list_with_operation( + list_uri, result_prop, full_properties, filter_args, None) + + @logged_api_call(blanked_properties=['bind-password'], properties_pos=1) + def create(self, properties): + """ + Create a new MFA Server Definition in this HMC. + + HMC/SE version requirements: + + * HMC version == 2.15.0 + + Authorization requirements: + + * Task permission to the "Manage Multi-factor Authentication" task. + + Parameters: + + properties (dict): Initial property values. + Allowable properties are defined in section 'Request body contents' + in section 'Create MFA Server Definition' in the :term:`HMC API` + book. + + Returns: + + MfaServerDefinition: + The resource object for the new MFA Server Definition. + The object will have its 'object-uri' property set as returned by + the HMC, and will also have the input properties set. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + """ + result = self.session.post( + self.console.uri + '/mfa-server-definitions', body=properties) + # There should not be overlaps, but just in case there are, the + # returned props should overwrite the input props: + props = copy.deepcopy(properties) + props.update(result) + name = props.get(self._name_prop, None) + uri = props[self._uri_prop] + mfa_server_definition = MfaServerDefinition(self, uri, name, props) + self._name_uri_cache.update(name, uri) + return mfa_server_definition + + +class MfaServerDefinition(BaseResource): + """ + Representation of a :term:`MFA Server Definition`. + + Derived from :class:`~zhmcclient.BaseResource`; see there for common + methods and attributes. + + Objects of this class are not directly created by the user; they are + returned from creation or list functions on their manager object + (in this case, :class:`~zhmcclient.MfaServerDefinitionManager`). + + HMC/SE version requirements: + + * HMC version == 2.15.0 + """ + + def __init__(self, manager, uri, name=None, properties=None): + # This function should not go into the docs. + # manager (:class:`~zhmcclient.MfaServerDefinitionManager`): + # Manager object for this resource object. + # uri (string): + # Canonical URI path of the resource. + # name (string): + # Name of the resource. + # properties (dict): + # Properties to be set for this resource object. May be `None` or + # empty. + assert isinstance(manager, MfaServerDefinitionManager), ( + "Console init: Expected manager type " + f"{MfaServerDefinitionManager}, got {type(manager)}") + super().__init__( + manager, uri, name, properties) + + @logged_api_call + def delete(self): + """ + Delete this MFA Server Definition. + + HMC/SE version requirements: + + * HMC version == 2.15.0 + + Authorization requirements: + + * Task permission to the "Manage Multi-factor Authentication" task. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + """ + # pylint: disable=protected-access + self.manager.session.delete(self.uri, resource=self) + self.manager._name_uri_cache.delete( + self.get_properties_local(self.manager._name_prop, None)) + self.cease_existence_local() + + @logged_api_call(blanked_properties=['bind-password'], properties_pos=1) + def update_properties(self, properties): + """ + Update writeable properties of this MFA Server Definitions. + + This method serializes with other methods that access or change + properties on the same Python object. + + HMC/SE version requirements: + + * HMC version == 2.15.0 + + Authorization requirements: + + * Task permission to the "MFA Server Definition Details" task. + + Parameters: + + properties (dict): New values for the properties to be updated. + Properties not to be updated are omitted. + Allowable properties are the properties with qualifier (w) in + section 'Data model' in section 'MFA Server Definition object' in + the :term:`HMC API` book. + + Raises: + + :exc:`~zhmcclient.HTTPError` + :exc:`~zhmcclient.ParseError` + :exc:`~zhmcclient.AuthError` + :exc:`~zhmcclient.ConnectionError` + """ + # pylint: disable=protected-access + self.manager.session.post(self.uri, resource=self, body=properties) + + is_rename = self.manager._name_prop in properties + if is_rename: + # Delete the old name from the cache + self.manager._name_uri_cache.delete(self.name) + self.update_properties_local(copy.deepcopy(properties)) + if is_rename: + # Add the new name to the cache + self.manager._name_uri_cache.update(self.name, self.uri) diff --git a/zhmcclient/_utils.py b/zhmcclient/_utils.py index d3608a89..cd4ab14d 100644 --- a/zhmcclient/_utils.py +++ b/zhmcclient/_utils.py @@ -67,7 +67,6 @@ RC_RESET_ACTIVATION_PROFILE = 'reset-activation-profile' RC_IMAGE_ACTIVATION_PROFILE = 'image-activation-profile' RC_LOAD_ACTIVATION_PROFILE = 'load-activation-profile' -RC_LDAP_SERVER_DEFINITION = 'ldap-server-definition' RC_LOGICAL_PARTITION = 'logical-partition' # # For CPCs in any mode and resources independent of CPCs: @@ -79,6 +78,8 @@ RC_USER_ROLE = 'user-role' RC_USER = 'user' RC_GROUP = 'group' +RC_LDAP_SERVER_DEFINITION = 'ldap-server-definition' +RC_MFA_SERVER_DEFINITION = 'mfa-server-definition' # Resource classes that are children of zhmcclient.Cpc RC_CHILDREN_CPC = ( @@ -101,6 +102,7 @@ RC_USER_ROLE, RC_USER, RC_LDAP_SERVER_DEFINITION, + RC_MFA_SERVER_DEFINITION, RC_CPC, # For unmanaged CPCs ) # Resource classes that are children of zhmcclient.Client (= top level) @@ -140,6 +142,7 @@ RC_IMAGE_ACTIVATION_PROFILE, RC_LOAD_ACTIVATION_PROFILE, RC_LDAP_SERVER_DEFINITION, + RC_MFA_SERVER_DEFINITION, RC_LOGICAL_PARTITION, RC_CONSOLE, RC_CPC, diff --git a/zhmcclient_mock/_hmc.py b/zhmcclient_mock/_hmc.py index fa91a019..b47ed510 100644 --- a/zhmcclient_mock/_hmc.py +++ b/zhmcclient_mock/_hmc.py @@ -39,6 +39,7 @@ 'FakedPasswordRuleManager', 'FakedPasswordRule', 'FakedTaskManager', 'FakedTask', 'FakedLdapServerDefinitionManager', 'FakedLdapServerDefinition', + 'FakedMfaServerDefinitionManager', 'FakedMfaServerDefinition', 'FakedActivationProfileManager', 'FakedActivationProfile', 'FakedAdapterManager', 'FakedAdapter', 'FakedCpcManager', 'FakedCpc', @@ -1148,6 +1149,8 @@ def __init__(self, manager, properties): self._tasks = FakedTaskManager(hmc=manager.hmc, console=self) self._ldap_server_definitions = FakedLdapServerDefinitionManager( hmc=manager.hmc, console=self) + self._mfa_server_definitions = FakedMfaServerDefinitionManager( + hmc=manager.hmc, console=self) self._unmanaged_cpcs = FakedUnmanagedCpcManager( hmc=manager.hmc, console=self) self._groups = FakedGroupManager(hmc=manager.hmc, console=self) @@ -1174,6 +1177,8 @@ def __repr__(self): f" _tasks = {repr_manager(self.tasks, indent=2)}\n" " _ldap_server_definitions = " f"{repr_manager(self.ldap_server_definitions, indent=2)}\n" + " _mfa_server_definitions = " + f"{repr_manager(self.mfa_server_definitions, indent=2)}\n" " _unmanaged_cpcs = " f"{repr_manager(self.unmanaged_cpcs, indent=2)}\n" f" _groups = {repr_manager(self.groups, indent=2)}\n" @@ -1244,6 +1249,14 @@ def ldap_server_definitions(self): """ return self._ldap_server_definitions + @property + def mfa_server_definitions(self): + """ + :class:`~zhmcclient_mock.FakedMfaServerDefinitionManager`: Access to + the faked MFA Server Definition resources of this Console. + """ + return self._mfa_server_definitions + @property def unmanaged_cpcs(self): """ @@ -1686,6 +1699,80 @@ def __init__(self, manager, properties): properties=properties) +class FakedMfaServerDefinitionManager(FakedBaseManager): + """ + A manager for faked MFA Server Definition resources within a faked HMC + (see :class:`zhmcclient_mock.FakedHmc`). + + Derived from :class:`zhmcclient_mock.FakedBaseManager`, see there for + common methods and attributes. + """ + + def __init__(self, hmc, console): + super().__init__( + hmc=hmc, + parent=console, + resource_class=FakedMfaServerDefinition, + base_uri=console.uri + '/mfa-server-definitions', + oid_prop='element-id', + uri_prop='element-uri', + class_value='mfa-server-definition', + name_prop='name', + case_insensitive_names=True) + + def add(self, properties): + # pylint: disable=useless-super-delegation + """ + Add a faked MFA Server Definition resource. + + Parameters: + + properties (dict): + Resource properties. + + Special handling and requirements for certain properties: + + * 'element-id' will be auto-generated with a unique value across + all instances of this resource type, if not specified. + * 'element-uri' will be auto-generated based upon the element ID, + if not specified. + * 'class' will be auto-generated to 'mfa-server-definition', + if not specified. + * All of the other class-soecific resource properties will be set + to a default value consistent with the HMC data model. + + Returns: + + :class:`~zhmcclient_mock.FakedMfaServerDefinition`: The faked + MfaServerDefinition resource. + """ + new_mfa = super().add(properties) + + # Resource type specific default values + # 'name' is required + new_mfa.properties.setdefault('description', '') + # 'hostname-ipaddr' is required + new_mfa.properties.setdefault('port', 6789) + new_mfa.properties.setdefault('replication-overwrite-possible', False) + + return new_mfa + + +class FakedMfaServerDefinition(FakedBaseResource): + """ + A faked MFA Server Definition resource within a faked HMC (see + :class:`zhmcclient_mock.FakedHmc`). + + Derived from :class:`zhmcclient_mock.FakedBaseResource`, see there for + common methods and attributes. + """ + + def __init__(self, manager, properties): + super().__init__( + manager=manager, + properties=properties) + + class FakedActivationProfileManager(FakedBaseManager): """ A manager for faked Activation Profile resources within a faked HMC (see diff --git a/zhmcclient_mock/_urihandler.py b/zhmcclient_mock/_urihandler.py index 667537df..ac5c9d0d 100644 --- a/zhmcclient_mock/_urihandler.py +++ b/zhmcclient_mock/_urihandler.py @@ -1801,6 +1801,86 @@ def post(method, hmc, uri, uri_parms, body, logon_required, lsd.update(body) +class MfaServerDefinitionsHandler: + """ + Handler class for HTTP methods on set of MfaServerDefinition resources. + """ + + valid_query_parms_get = ['name'] + + returned_props = ['element-uri', 'name'] + + @classmethod + def get(cls, method, hmc, uri, uri_parms, logon_required): + # pylint: disable=unused-argument + """Operation: List MFA Server Definitions.""" + uri, query_parms = parse_query_parms(method, uri) + check_invalid_query_parms( + method, uri, query_parms, cls.valid_query_parms_get) + filter_args = query_parms + + try: + console = hmc.consoles.lookup_by_oid(None) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc # zhmcclient_mock.InvalidResourceError + result_mfa_srv_defs = [] + for mfa_srv_def in console.mfa_server_definitions.list(filter_args): + result_mfa_srv_def = {} + for prop in cls.returned_props: + result_mfa_srv_def[prop] = \ + mfa_srv_def.properties.get(prop) + result_mfa_srv_defs.append(result_mfa_srv_def) + return {'mfa-server-definitions': result_mfa_srv_defs} + + @staticmethod + def post(method, hmc, uri, uri_parms, body, logon_required, + wait_for_completion): + # pylint: disable=unused-argument + """Operation: Create MFA Server Definition.""" + assert wait_for_completion is True # synchronous operation + try: + console = hmc.consoles.lookup_by_oid(None) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc # zhmcclient_mock.InvalidResourceError + check_required_fields(method, uri, body, + ['name', 'hostname-ipaddr']) + new_mfa_srv_def = console.mfa_server_definitions.add(body) + return {'element-uri': new_mfa_srv_def.uri} + + +class MfaServerDefinitionHandler(GenericGetPropertiesHandler, + GenericDeleteHandler): + """ + Handler class for HTTP methods on single MfaServerDefinition resource. + """ + + @staticmethod + def post(method, hmc, uri, uri_parms, body, logon_required, + wait_for_completion): + # pylint: disable=unused-argument + """Operation: Update MfaServerDefinition Properties.""" + try: + mfa = hmc.lookup_by_uri(uri) + except KeyError: + new_exc = InvalidResourceError(method, uri) + new_exc.__cause__ = None + raise new_exc # zhmcclient_mock.InvalidResourceError + # Check whether requested properties are modifiable + check_writable( + method, uri, body, + [ + 'name', + 'description', + 'hostname-ipaddr', + 'port', + ]) + mfa.update(body) + + class CpcsHandler: """ Handler class for HTTP methods on set of Cpc resources. @@ -6178,6 +6258,11 @@ def post(method, hmc, uri, uri_parms, body, logon_required, (r'/api/console/ldap-server-definitions/([^?/]+)(?:\?(.*))?', LdapServerDefinitionHandler), + (r'/api/console/mfa-server-definitions(?:\?(.*))?', + MfaServerDefinitionsHandler), + (r'/api/console/mfa-server-definitions/([^?/]+)(?:\?(.*))?', + MfaServerDefinitionHandler), + (r'/api/cpcs(?:\?(.*))?', CpcsHandler), (r'/api/cpcs/([^?/]+)(?:\?(.*))?', CpcHandler), (r'/api/cpcs/([^/]+)/operations/set-cpc-power-save',