diff --git a/lib/ansible/modules/network/f5/bigip_device_auth_ldap.py b/lib/ansible/modules/network/f5/bigip_device_auth_ldap.py new file mode 100644 index 00000000000..e66f66cb2a6 --- /dev/null +++ b/lib/ansible/modules/network/f5/bigip_device_auth_ldap.py @@ -0,0 +1,775 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2018, F5 Networks Inc. +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'certified'} + +DOCUMENTATION = r''' +--- +module: bigip_device_auth_ldap +short_description: Manage LDAP device authentication settings on BIG-IP +description: + - Manage LDAP device authentication settings on BIG-IP. +version_added: 2.8 +options: + servers: + description: + - Specifies the LDAP servers that the system must use to obtain + authentication information. You must specify a server when you + create an LDAP configuration object. + port: + description: + - Specifies the port that the system uses for access to the remote host server. + - When configuring LDAP device authentication for the first time, if this parameter + is not specified, the default port is C(389). + remote_directory_tree: + description: + - Specifies the file location (tree) of the user authentication database on the + server. + scope: + description: + - Specifies the level of the remote Active Directory or LDAP directory that the + system should search for the user authentication. + choices: + - sub + - one + - base + bind_dn: + description: + - Specifies the distinguished name for the Active Directory or LDAP server user + ID. + - The BIG-IP client authentication module does not support Active Directory or + LDAP servers that do not perform bind referral when authenticating referred + accounts. + - Therefore, if you plan to use Active Directory or LDAP as your authentication + source and want to use referred accounts, make sure your servers perform bind + referral. + bind_password: + description: + - Specifies a password for the Active Directory or LDAP server user ID. + user_template: + description: + - Specifies the distinguished name of the user who is logging on. + - You specify the template as a variable that the system replaces with user-specific + information during the logon attempt. + - For example, you could specify a user template such as C(%s@siterequest.com) or + C(uxml:id=%s,ou=people,dc=siterequest,dc=com). + - When a user attempts to log on, the system replaces C(%s) with the name the user + specified in the Basic Authentication dialog box, and passes that as the + distinguished name for the bind operation. + - The system passes the associated password as the password for the bind operation. + - This field can contain only one C(%s) and cannot contain any other format + specifiers. + check_member_attr: + description: + - Checks the user's member attribute in the remote LDAP or AD group. + type: bool + ssl: + description: + - Specifies whether the system uses an SSL port to communicate with the LDAP server. + choices: + - "yes" + - "no" + - start-tls + ssl_ca_cert: + description: + - Specifies the name of an SSL certificate from a certificate authority (CA). + - To remove this value, use the reserved value C(none). + ssl_client_key: + description: + - Specifies the name of an SSL client key. + - To remove this value, use the reserved value C(none). + ssl_client_cert: + description: + - Specifies the name of an SSL client certificate. + - To remove this value, use the reserved value C(none). + ssl_check_peer: + description: + - Specifies whether the system checks an SSL peer, as a result of which the + system requires and verifies the server certificate. + type: bool + login_ldap_attr: + description: + - Specifies the LDAP directory attribute containing the local user name that is + associated with the selected directory entry. + - When configuring LDAP device authentication for the first time, if this parameter + is not specified, the default port is C(samaccountname). + fallback_to_local: + description: + - Specifies that the system uses the Local authentication method if the remote + authentication method is not available. + type: bool + state: + description: + - When C(present), ensures the device authentication method exists. + - When C(absent), ensures the device authentication method does not exist. + default: present + choices: + - present + - absent + update_password: + description: + - C(always) will always update the C(bind_password). + - C(on_create) will only set the C(bind_password) for newly created authentication + mechanisms. + default: always + choices: + - always + - on_create +extends_documentation_fragment: f5 +author: + - Tim Rupp (@caphrim007) +''' + +EXAMPLES = r''' +- name: Create an LDAP authentication object + bigip_device_auth_ldap: + name: foo + provider: + password: secret + server: lb.mydomain.com + user: admin + delegate_to: localhost +''' + +RETURN = r''' +param1: + description: The new param1 value of the resource. + returned: changed + type: bool + sample: true +param2: + description: The new param2 value of the resource. + returned: changed + type: string + sample: Foo is bar +''' + +from ansible.module_utils.basic import AnsibleModule + +try: + from library.module_utils.network.f5.bigip import F5RestClient + from library.module_utils.network.f5.common import F5ModuleError + from library.module_utils.network.f5.common import AnsibleF5Parameters + from library.module_utils.network.f5.common import cleanup_tokens + from library.module_utils.network.f5.common import fq_name + from library.module_utils.network.f5.common import transform_name + from library.module_utils.network.f5.common import f5_argument_spec + from library.module_utils.network.f5.common import exit_json + from library.module_utils.network.f5.common import fail_json + from library.module_utils.network.f5.common import flatten_boolean + from library.module_utils.network.f5.compare import cmp_str_with_none +except ImportError: + from ansible.module_utils.network.f5.bigip import F5RestClient + from ansible.module_utils.network.f5.common import F5ModuleError + from ansible.module_utils.network.f5.common import AnsibleF5Parameters + from ansible.module_utils.network.f5.common import cleanup_tokens + from ansible.module_utils.network.f5.common import fq_name + from ansible.module_utils.network.f5.common import transform_name + from ansible.module_utils.network.f5.common import f5_argument_spec + from ansible.module_utils.network.f5.common import exit_json + from ansible.module_utils.network.f5.common import fail_json + from ansible.module_utils.network.f5.common import flatten_boolean + from ansible.module_utils.network.f5.compare import cmp_str_with_none + + +class Parameters(AnsibleF5Parameters): + api_map = { + 'bindDn': 'bind_dn', + 'bindPw': 'bind_password', + 'userTemplate': 'user_template', + 'fallback': 'fallback_to_local', + 'loginAttribute': 'login_ldap_attr', + 'sslCheckPeer': 'ssl_check_peer', + 'sslClientCert': 'ssl_client_cert', + 'sslClientKey': 'ssl_client_key', + 'sslCaCertFile': 'ssl_ca_cert', + 'checkRolesGroup': 'check_member_attr', + 'searchBaseDn': 'remote_directory_tree', + } + + api_attributes = [ + 'bindDn', + 'bindPw', + 'checkRolesGroup', + 'loginAttribute', + 'port', + 'scope', + 'searchBaseDn', + 'servers', + 'ssl', + 'sslCaCertFile', + 'sslCheckPeer', + 'sslClientCert', + 'sslClientKey', + 'userTemplate', + ] + + returnables = [ + 'bind_dn', + 'bind_password', + 'check_member_attr', + 'fallback_to_local', + 'login_ldap_attr', + 'port', + 'remote_directory_tree', + 'scope', + 'servers', + 'ssl', + 'ssl_ca_cert', + 'ssl_check_peer', + 'ssl_client_cert', + 'ssl_client_key', + 'user_template', + ] + + updatables = [ + 'bind_dn', + 'bind_password', + 'check_member_attr', + 'fallback_to_local', + 'login_ldap_attr', + 'port', + 'remote_directory_tree', + 'scope', + 'servers', + 'ssl', + 'ssl_ca_cert', + 'ssl_check_peer', + 'ssl_client_cert', + 'ssl_client_key', + 'user_template', + ] + + @property + def ssl_ca_cert(self): + if self._values['ssl_ca_cert'] is None: + return None + elif self._values['ssl_ca_cert'] in ['none', '']: + return '' + return fq_name(self.partition, self._values['ssl_ca_cert']) + + @property + def ssl_client_key(self): + if self._values['ssl_client_key'] is None: + return None + elif self._values['ssl_client_key'] in ['none', '']: + return '' + return fq_name(self.partition, self._values['ssl_client_key']) + + @property + def ssl_client_cert(self): + if self._values['ssl_client_cert'] is None: + return None + elif self._values['ssl_client_cert'] in ['none', '']: + return '' + return fq_name(self.partition, self._values['ssl_client_cert']) + + @property + def ssl_check_peer(self): + return flatten_boolean(self._values['ssl_check_peer']) + + @property + def fallback_to_local(self): + return flatten_boolean(self._values['fallback_to_local']) + + @property + def check_member_attr(self): + return flatten_boolean(self._values['check_member_attr']) + + @property + def login_ldap_attr(self): + if self._values['login_ldap_attr'] is None: + return None + elif self._values['login_ldap_attr'] in ['none', '']: + return '' + return self._values['login_ldap_attr'] + + @property + def user_template(self): + if self._values['user_template'] is None: + return None + elif self._values['user_template'] in ['none', '']: + return '' + return self._values['user_template'] + + @property + def ssl(self): + if self._values['ssl'] is None: + return None + elif self._values['ssl'] == 'start-tls': + return 'start-tls' + return flatten_boolean(self._values['ssl']) + + +class ApiParameters(Parameters): + pass + + +class ModuleParameters(Parameters): + pass + + +class Changes(Parameters): + def to_return(self): + result = {} + try: + for returnable in self.returnables: + result[returnable] = getattr(self, returnable) + result = self._filter_params(result) + except Exception: + pass + return result + + +class UsableChanges(Changes): + @property + def ssl_check_peer(self): + if self._values['ssl_check_peer'] is None: + return None + elif self._values['ssl_check_peer'] == 'yes': + return 'enabled' + return 'disabled' + + @property + def fallback_to_local(self): + if self._values['fallback_to_local'] is None: + return None + elif self._values['fallback_to_local'] == 'yes': + return 'true' + return 'false' + + @property + def check_member_attr(self): + if self._values['check_member_attr'] is None: + return None + elif self._values['check_member_attr'] == 'yes': + return 'enabled' + return 'disabled' + + @property + def ssl(self): + if self._values['ssl'] is None: + return None + elif self._values['ssl'] == 'start-tls': + return 'start-tls' + elif self._values['ssl'] == 'yes': + return 'enabled' + return 'disabled' + + +class ReportableChanges(Changes): + @property + def bind_password(self): + return None + + @property + def ssl_check_peer(self): + return flatten_boolean(self._values['ssl_check_peer']) + + @property + def check_member_attr(self): + return flatten_boolean(self._values['check_member_attr']) + + @property + def ssl(self): + if self._values['ssl'] is None: + return None + elif self._values['ssl'] == 'start-tls': + return 'start-tls' + return flatten_boolean(self._values['ssl']) + + +class Difference(object): + def __init__(self, want, have=None): + self.want = want + self.have = have + + def compare(self, param): + try: + result = getattr(self, param) + return result + except AttributeError: + return self.__default(param) + + def __default(self, param): + attr1 = getattr(self.want, param) + try: + attr2 = getattr(self.have, param) + if attr1 != attr2: + return attr1 + except AttributeError: + return attr1 + + @property + def login_ldap_attr(self): + return cmp_str_with_none(self.want.login_ldap_attr, self.have.login_ldap_attr) + + @property + def user_template(self): + return cmp_str_with_none(self.want.user_template, self.have.user_template) + + @property + def ssl_ca_cert(self): + return cmp_str_with_none(self.want.ssl_ca_cert, self.have.ssl_ca_cert) + + @property + def ssl_client_key(self): + return cmp_str_with_none(self.want.ssl_client_key, self.have.ssl_client_key) + + @property + def ssl_client_cert(self): + return cmp_str_with_none(self.want.ssl_client_cert, self.have.ssl_client_cert) + + @property + def bind_password(self): + if self.want.bind_password != self.have.bind_password and self.want.update_password == 'always': + return self.want.bind_password + + +class ModuleManager(object): + def __init__(self, *args, **kwargs): + self.module = kwargs.get('module', None) + self.client = kwargs.get('client', None) + self.want = ModuleParameters(params=self.module.params) + self.have = ApiParameters() + self.changes = UsableChanges() + + def _set_changed_options(self): + changed = {} + for key in Parameters.returnables: + if getattr(self.want, key) is not None: + changed[key] = getattr(self.want, key) + if changed: + self.changes = UsableChanges(params=changed) + + def _update_changed_options(self): + diff = Difference(self.want, self.have) + updatables = Parameters.updatables + changed = dict() + for k in updatables: + change = diff.compare(k) + if change is None: + continue + else: + if isinstance(change, dict): + changed.update(change) + else: + changed[k] = change + if changed: + self.changes = UsableChanges(params=changed) + return True + return False + + def _announce_deprecations(self, result): + warnings = result.pop('__warnings', []) + for warning in warnings: + self.client.module.deprecate( + msg=warning['msg'], + version=warning['version'] + ) + + def update_auth_source_on_device(self, source): + """Set the system auth source. + + Configuring the authentication source is only one step in the process of setting + up an auth source. The other step is to inform the system of the auth source + you want to use. + + This method is used for situations where + + * The ``use_for_auth`` parameter is set to ``yes`` + * The ``use_for_auth`` parameter is set to ``no`` + * The ``state`` parameter is set to ``absent`` + + When ``state`` equal to ``absent``, before you can delete the TACACS+ configuration, + you must set the system auth to "something else". The system ships with a system + auth called "local", so this is the logical "something else" to use. + + When ``use_for_auth`` is no, the same situation applies as when ``state`` equal + to ``absent`` is done above. + + When ``use_for_auth`` is ``yes``, this method will set the current system auth + state to TACACS+. + + Arguments: + source (string): The source that you want to set on the device. + """ + params = dict( + type=source + ) + uri = 'https://{0}:{1}/mgmt/tm/auth/source/'.format( + self.client.provider['server'], + self.client.provider['server_port'] + ) + + resp = self.client.api.patch(uri, json=params) + try: + response = resp.json() + except ValueError as ex: + raise F5ModuleError(str(ex)) + + if 'code' in response and response['code'] == 400: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + + def update_fallback_on_device(self, fallback): + params = dict( + fallback=fallback + ) + uri = 'https://{0}:{1}/mgmt/tm/auth/source/'.format( + self.client.provider['server'], + self.client.provider['server_port'] + ) + + resp = self.client.api.patch(uri, json=params) + try: + response = resp.json() + except ValueError as ex: + raise F5ModuleError(str(ex)) + + if 'code' in response and response['code'] == 400: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + + def exec_module(self): + changed = False + result = dict() + state = self.want.state + + if state == "present": + changed = self.present() + elif state == "absent": + changed = self.absent() + + reportable = ReportableChanges(params=self.changes.to_return()) + changes = reportable.to_return() + result.update(**changes) + result.update(dict(changed=changed)) + self._announce_deprecations(result) + return result + + def present(self): + if self.exists(): + return self.update() + else: + return self.create() + + def absent(self): + if self.exists(): + return self.remove() + return False + + def should_update(self): + result = self._update_changed_options() + if result: + return True + return False + + def update(self): + self.have = self.read_current_from_device() + if not self.should_update(): + return False + if self.module.check_mode: + return True + self.update_on_device() + if self.want.fallback_to_local == 'yes': + self.update_fallback_on_device('true') + elif self.want.fallback_to_local == 'no': + self.update_fallback_on_device('false') + return True + + def remove(self): + if self.module.check_mode: + return True + self.update_auth_source_on_device('local') + self.remove_from_device() + if self.exists(): + raise F5ModuleError("Failed to delete the resource.") + return True + + def create(self): + self._set_changed_options() + if self.module.check_mode: + return True + self.create_on_device() + if self.want.fallback_to_local == 'yes': + self.update_fallback_on_device('true') + elif self.want.fallback_to_local == 'no': + self.update_fallback_on_device('false') + return True + + def exists(self): + uri = "https://{0}:{1}/mgmt/tm/auth/ldap/{2}".format( + self.client.provider['server'], + self.client.provider['server_port'], + transform_name('Common', 'system-auth') + ) + resp = self.client.api.get(uri) + try: + response = resp.json() + except ValueError: + return False + if resp.status == 404 or 'code' in response and response['code'] == 404: + return False + return True + + def create_on_device(self): + params = self.changes.api_params() + params['name'] = 'system-auth' + params['partition'] = 'Common' + uri = "https://{0}:{1}/mgmt/tm/auth/ldap/".format( + self.client.provider['server'], + self.client.provider['server_port'], + ) + resp = self.client.api.post(uri, json=params) + try: + response = resp.json() + except ValueError as ex: + raise F5ModuleError(str(ex)) + + if 'code' in response and response['code'] in [400, 409]: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + return True + + def update_on_device(self): + params = self.changes.api_params() + if not params: + return + uri = "https://{0}:{1}/mgmt/tm/auth/ldap/{2}".format( + self.client.provider['server'], + self.client.provider['server_port'], + transform_name('Common', 'system-auth') + ) + resp = self.client.api.patch(uri, json=params) + try: + response = resp.json() + except ValueError as ex: + raise F5ModuleError(str(ex)) + + if 'code' in response and response['code'] == 400: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + + def remove_from_device(self): + uri = "https://{0}:{1}/mgmt/tm/auth/ldap/{2}".format( + self.client.provider['server'], + self.client.provider['server_port'], + transform_name('Common', 'system-auth') + ) + response = self.client.api.delete(uri) + if response.status == 200: + return True + raise F5ModuleError(response.content) + + def read_current_from_device(self): + uri = "https://{0}:{1}/mgmt/tm/auth/ldap/{2}".format( + self.client.provider['server'], + self.client.provider['server_port'], + transform_name('Common', 'system-auth') + ) + resp = self.client.api.get(uri) + try: + response = resp.json() + except ValueError as ex: + raise F5ModuleError(str(ex)) + + if 'code' in response and response['code'] == 400: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + result = ApiParameters(params=response) + + uri = 'https://{0}:{1}/mgmt/tm/auth/source/'.format( + self.client.provider['server'], + self.client.provider['server_port'] + ) + resp = self.client.api.get(uri) + try: + response = resp.json() + except ValueError as ex: + raise F5ModuleError(str(ex)) + + if 'code' in response and response['code'] == 400: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + result.update({'fallback': response['fallback']}) + return result + + +class ArgumentSpec(object): + def __init__(self): + self.supports_check_mode = True + argument_spec = dict( + servers=dict(type='list'), + port=dict(type='int'), + remote_directory_tree=dict(), + scope=dict( + choices=['sub', 'one', 'base'] + ), + bind_dn=dict(), + bind_password=dict(no_log=True), + user_template=dict(), + check_member_attr=dict(type='bool'), + ssl=dict( + choices=['yes', 'no', 'start-tls'] + ), + ssl_ca_cert=dict(), + ssl_client_key=dict(), + ssl_client_cert=dict(), + ssl_check_peer=dict(type='bool'), + login_ldap_attr=dict(), + fallback_to_local=dict(type='bool'), + update_password=dict( + default='always', + choices=['always', 'on_create'] + ), + state=dict(default='present', choices=['absent', 'present']), + ) + self.argument_spec = {} + self.argument_spec.update(f5_argument_spec) + self.argument_spec.update(argument_spec) + + +def main(): + spec = ArgumentSpec() + + module = AnsibleModule( + argument_spec=spec.argument_spec, + supports_check_mode=spec.supports_check_mode, + ) + + client = F5RestClient(**module.params) + + try: + mm = ModuleManager(module=module, client=client) + results = mm.exec_module() + cleanup_tokens(client) + exit_json(module, results, client) + except F5ModuleError as ex: + cleanup_tokens(client) + fail_json(module, ex, client) + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/network/f5/test_bigip_device_auth_ldap.py b/test/units/modules/network/f5/test_bigip_device_auth_ldap.py new file mode 100644 index 00000000000..3b611a901cc --- /dev/null +++ b/test/units/modules/network/f5/test_bigip_device_auth_ldap.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2018, F5 Networks Inc. +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import os +import json +import pytest +import sys + +if sys.version_info < (2, 7): + pytestmark = pytest.mark.skip("F5 Ansible modules require Python >= 2.7") + +from ansible.module_utils.basic import AnsibleModule + +try: + from library.modules.bigip_device_auth_ldap import ApiParameters + from library.modules.bigip_device_auth_ldap import ModuleParameters + from library.modules.bigip_device_auth_ldap import ModuleManager + from library.modules.bigip_device_auth_ldap import ArgumentSpec + + # In Ansible 2.8, Ansible changed import paths. + from test.units.compat import unittest + from test.units.compat.mock import Mock + from test.units.compat.mock import patch + + from test.units.modules.utils import set_module_args +except ImportError: + from ansible.modules.network.f5.bigip_device_auth_ldap import ApiParameters + from ansible.modules.network.f5.bigip_device_auth_ldap import ModuleParameters + from ansible.modules.network.f5.bigip_device_auth_ldap import ModuleManager + from ansible.modules.network.f5.bigip_device_auth_ldap import ArgumentSpec + + # Ansible 2.8 imports + from units.compat import unittest + from units.compat.mock import Mock + from units.compat.mock import patch + + from units.modules.utils import set_module_args + + +fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures') +fixture_data = {} + + +def load_fixture(name): + path = os.path.join(fixture_path, name) + + if path in fixture_data: + return fixture_data[path] + + with open(path) as f: + data = f.read() + + try: + data = json.loads(data) + except Exception: + pass + + fixture_data[path] = data + return data + + +class TestParameters(unittest.TestCase): + def test_module_parameters(self): + args = dict( + servers=['10.10.10.10', '10.10.10.11'], + port=389, + remote_directory_tree='foo', + scope='base', + bind_dn='bar', + bind_password='secret', + user_template='alice', + check_member_attr=False, + ssl='no', + ssl_ca_cert='default.crt', + ssl_client_key='default.key', + ssl_client_cert='default1.crt', + ssl_check_peer=True, + login_ldap_attr='bob', + fallback_to_local=True, + update_password='on_create', + ) + p = ApiParameters(params=args) + assert p.port == 389 + assert p.servers == ['10.10.10.10', '10.10.10.11'] + assert p.remote_directory_tree == 'foo' + assert p.scope == 'base' + assert p.bind_dn == 'bar' + assert p.bind_password == 'secret' + assert p.user_template == 'alice' + assert p.check_member_attr == 'no' + assert p.ssl == 'no' + assert p.ssl_ca_cert == '/Common/default.crt' + assert p.ssl_client_key == '/Common/default.key' + assert p.ssl_client_cert == '/Common/default1.crt' + assert p.ssl_check_peer == 'yes' + assert p.login_ldap_attr == 'bob' + assert p.fallback_to_local == 'yes' + assert p.update_password == 'on_create' + + +class TestManager(unittest.TestCase): + + def setUp(self): + self.spec = ArgumentSpec() + + def test_create(self, *args): + set_module_args(dict( + servers=['10.10.10.10', '10.10.10.11'], + update_password='on_create', + state='present', + provider=dict( + password='admin', + server='localhost', + user='admin' + ) + )) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode + ) + + # Override methods to force specific logic in the module to happen + mm = ModuleManager(module=module) + mm.exists = Mock(return_value=False) + mm.create_on_device = Mock(return_value=True) + mm.update_auth_source_on_device = Mock(return_value=True) + + results = mm.exec_module() + assert results['changed'] is True