From 359d97f01bf1644d25ef3d8a84afee70f82ce2d1 Mon Sep 17 00:00:00 2001 From: Tim Rupp Date: Sun, 19 Aug 2018 17:32:19 -0400 Subject: [PATCH] Adds bigip_device_auth module (#44373) This module can be used to configure auth settings to the mgmt interface on a BIG-IP. --- .../modules/network/f5/bigip_device_auth.py | 805 ++++++++++++++++++ .../network/f5/test_bigip_device_auth.py | 131 +++ 2 files changed, 936 insertions(+) create mode 100644 lib/ansible/modules/network/f5/bigip_device_auth.py create mode 100644 test/units/modules/network/f5/test_bigip_device_auth.py diff --git a/lib/ansible/modules/network/f5/bigip_device_auth.py b/lib/ansible/modules/network/f5/bigip_device_auth.py new file mode 100644 index 00000000000..2c4cb313bf8 --- /dev/null +++ b/lib/ansible/modules/network/f5/bigip_device_auth.py @@ -0,0 +1,805 @@ +#!/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': 'community'} + +DOCUMENTATION = r''' +--- +module: bigip_device_auth +short_description: Manage system authentication on a BIG-IP +description: + - Manage the system authentication configuration. This module can assist in configuring + a number of different system authentication types. Note that this module can not be used + to configure APM authentication types. +version_added: 2.7 +options: + type: + description: + - The authentication type to manage with this module. + - Take special note that the parameters supported by this module will vary depending + on the C(type) that you are configuring. + - This module only supports a subset, at this time, of the total available auth types. + choices: + - tacacs + - local + servers: + description: + - Specifies a list of the IPv4 addresses for servers using the Terminal + Access Controller Access System (TACACS)+ protocol with which the system + communicates to obtain authorization data. + - For each address, an alternate TCP port number may be optionally specified + by specifying the C(port) key. + - If no port number is specified, the default port C(49163) is used. + - This parameter is supported by the C(tacacs) type. + suboptions: + address: + description: + - The IP address of the server. + - This field is required, unless you are specifying a simple list of servers. + In that case, the simple list can specify server IPs. See examples for + more clarification. + port: + description: + - The port of the server. + default: 49163 + secret: + description: + - Secret key used to encrypt and decrypt packets sent or received from the + server. + - B(Do not) use the pound/hash sign in the secret for TACACS+ servers. + - When configuring TACACS+ auth for the first time, this value is required. + service_name: + description: + - Specifies the name of the service that the user is requesting to be + authorized to use. + - Identifying what the user is asking to be authorized for, enables the + TACACS+ server to behave differently for different types of authorization + requests. + - When configuring this form of system authentication, this setting is required. + - Note that the majority of TACACS+ implementations are of service type C(ppp), + so try that first. + choices: + - slip + - ppp + - arap + - shell + - tty-daemon + - connection + - system + - firewall + protocol_name: + description: + - Specifies the protocol associated with the value specified in C(service_name), + which is a subset of the associated service being used for client authorization + or system accounting. + - Note that the majority of TACACS+ implementations are of protocol type C(ip), + so try that first. + choices: + - lcp + - ip + - ipx + - atalk + - vines + - lat + - xremote + - tn3270 + - telnet + - rlogin + - pad + - vpdn + - ftp + - http + - deccp + - osicp + - unknown + authentication: + description: + - Specifies the process the system employs when sending authentication requests. + - When C(use-first-server), specifies that the system sends authentication + attempts to only the first server in the list. + - When C(use-all-servers), specifies that the system sends an authentication + request to each server until authentication succeeds, or until the system has + sent a request to all servers in the list. + - This parameter is supported by the C(tacacs) type. + choices: + - use-first-server + - use-all-servers + use_for_auth: + description: + - Specifies whether or not this auth source is put in use on the system. + type: bool + state: + description: + - The state of the authentication configuration on the system. + - When C(present), guarantees that the system is configured for the specified C(type). + - When C(absent), sets the system auth source back to C(local). + default: present + choices: + - absent + - present + update_secret: + description: + - C(always) will allow to update secrets if the user chooses to do so. + - C(on_create) will only set the secret when a C(use_auth_source) is C(yes) + and TACACS+ is not currently the auth source. + default: always + choices: + - always + - on_create +extends_documentation_fragment: f5 +author: + - Tim Rupp (@caphrim007) +''' + +EXAMPLES = r''' +- name: Set the system auth to TACACS+, default server port + bigip_device_auth: + type: tacacs + authentication: use-all-servers + protocol_name: ip + secret: secret + servers: + - 10.10.10.10 + - 10.10.10.11 + service_name: ppp + state: present + use_for_auth: yes + provider: + password: secret + server: lb.mydomain.com + user: admin + delegate_to: localhost + +- name: Set the system auth to TACACS+, override server port + bigip_device_auth: + type: tacacs + authentication: use-all-servers + protocol_name: ip + secret: secret + servers: + - address: 10.10.10.10 + port: 1234 + - 10.10.10.11 + service_name: ppp + use_for_auth: yes + state: present + provider: + password: secret + server: lb.mydomain.com + user: admin + delegate_to: localhost +''' + +RETURN = r''' +servers: + description: List of servers used in TACACS authentication. + returned: changed + type: list + sample: ['1.2.2.1', '4.5.5.4'] +authentication: + description: Process the system uses to serve authentication requests when using TACACS. + returned: changed + type: string + sample: use-all-servers +service_name: + description: Name of the service the user is requesting to be authorized to use. + returned: changed + type: string + sample: ppp +protocol_name: + description: Name of the protocol associated with C(service_name) used for client authentication. + returned: changed + type: string + sample: ip +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.six import string_types + +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 f5_argument_spec + from library.module_utils.network.f5.common import exit_json + from library.module_utils.network.f5.common import fail_json +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 f5_argument_spec + from ansible.module_utils.network.f5.common import exit_json + from ansible.module_utils.network.f5.common import fail_json + + +class BaseParameters(AnsibleF5Parameters): + @property + def api_map(self): + return {} + + @property + def api_attributes(self): + return [] + + @property + def returnables(self): + return [] + + @property + def updatables(self): + return [] + + +class BaseApiParameters(BaseParameters): + pass + + +class BaseModuleParameters(BaseParameters): + pass + + +class BaseChanges(BaseParameters): + 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 BaseUsableChanges(BaseChanges): + pass + + +class BaseReportableChanges(BaseChanges): + pass + + +class TacacsParameters(BaseParameters): + api_map = { + 'protocol': 'protocol_name', + 'service': 'service_name' + } + + api_attributes = [ + 'authentication', + 'protocol', + 'service', + 'secret', + 'servers' + ] + + returnables = [ + 'servers', + 'secret', + 'authentication', + 'service_name', + 'protocol_name' + ] + + updatables = [ + 'servers', + 'secret', + 'authentication', + 'service_name', + 'protocol_name', + 'auth_source', + ] + + +class TacacsApiParameters(TacacsParameters): + pass + + +class TacacsModuleParameters(TacacsParameters): + @property + def servers(self): + if self._values['servers'] is None: + return None + result = [] + for server in self._values['servers']: + if isinstance(server, dict): + if 'address' not in server: + raise F5ModuleError( + "An 'address' field must be provided when specifying separate fields to the 'servers' parameter." + ) + address = server.get('address') + port = server.get('port', 49163) + elif isinstance(server, string_types): + address = server + port = 49163 + result.append('{0}:{1}'.format(address, port)) + return result + + @property + def auth_source(self): + return 'tacacs' + + +class TacacsChanges(BaseChanges, TacacsParameters): + pass + + +class TacacsUsableChanges(TacacsChanges): + pass + + +class TacacsReportableChanges(TacacsChanges): + @property + def secret(self): + return None + + +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): + want = getattr(self.want, param) + try: + have = getattr(self.have, param) + if want != have: + return want + except AttributeError: + return want + + @property + def secret(self): + if self.want.secret != self.have.secret and self.want.update_secret == 'always': + return self.want.secret + + +class BaseManager(object): + def _set_changed_options(self): + changed = {} + for key in self.returnables: + if getattr(self.want, key) is not None: + changed[key] = getattr(self.want, key) + if changed: + self.changes = self.get_usable_changes(params=changed) + + def _update_changed_options(self): + diff = Difference(self.want, self.have) + updatables = self.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 = self.get_usable_changes(params=changed) + return True + return False + + def should_update(self): + result = self._update_changed_options() + if result: + return True + return False + + 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 = self.get_reportable_changes(params=self.changes.to_return()) + changes = reportable.to_return() + result.update(**changes) + result.update(dict(changed=changed)) + self._announce_deprecations(result) + return result + + def _announce_deprecations(self, result): + warnings = result.pop('__warnings', []) + for warning in warnings: + self.client.module.deprecate( + msg=warning['msg'], + version=warning['version'] + ) + + def present(self): + if self.exists(): + return self.update() + return self.create() + + def absent(self): + if self.exists(): + return self.remove() + return False + + 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 read_current_auth_source_from_device(self): + 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) + return response['type'] + + +class LocalManager(BaseManager): + def __init__(self, *args, **kwargs): + self.module = kwargs.get('module', None) + self.client = kwargs.get('client', None) + self.want = self.get_module_parameters(params=self.module.params) + self.have = self.get_api_parameters() + self.changes = self.get_usable_changes() + + @property + def returnables(self): + return [] + + @property + def updatables(self): + return [] + + def get_parameters(self, params=None): + return BaseParameters(params=params) + + def get_usable_changes(self, params=None): + return BaseUsableChanges(params=params) + + def get_reportable_changes(self, params=None): + return BaseReportableChanges(params=params) + + def get_module_parameters(self, params=None): + return BaseModuleParameters(params=params) + + def get_api_parameters(self, params=None): + return BaseApiParameters(params=params) + + def exists(self): + 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) + if response['type'] == 'local': + return True + return False + + def create(self): + self._set_changed_options() + if self.module.check_mode: + return True + self.update_auth_source_on_device('local') + return True + + def present(self): + if not self.exists(): + return self.create() + + def absent(self): + raise F5ModuleError( + "The 'local' type cannot be removed. " + "Instead, specify a 'state' of 'present' on other types." + ) + + +class TacacsManager(BaseManager): + def __init__(self, *args, **kwargs): + self.module = kwargs.get('module', None) + self.client = kwargs.get('client', None) + self.want = self.get_module_parameters(params=self.module.params) + self.have = self.get_api_parameters() + self.changes = self.get_usable_changes() + + @property + def returnables(self): + return TacacsParameters.returnables + + @property + def updatables(self): + return TacacsParameters.updatables + + def get_usable_changes(self, params=None): + return TacacsUsableChanges(params=params) + + def get_reportable_changes(self, params=None): + return TacacsReportableChanges(params=params) + + def get_module_parameters(self, params=None): + return TacacsModuleParameters(params=params) + + def get_api_parameters(self, params=None): + return TacacsApiParameters(params=params) + + def exists(self): + uri = "https://{0}:{1}/mgmt/tm/auth/tacacs/~Common~system-auth".format( + self.client.provider['server'], + self.client.provider['server_port'], + ) + 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(self): + self._set_changed_options() + if self.module.check_mode: + return True + self.create_on_device() + if self.want.use_for_auth: + self.update_auth_source_on_device('tacacs') + return True + + def update(self): + self.have = self.read_current_from_device() + if not self.should_update(): + return False + if self.module.check_mode: + return True + result = False + if self.update_on_device(): + result = True + if self.want.use_for_auth and self.changes.auth_source == 'tacacs': + self.update_auth_source_on_device('tacacs') + result = True + return result + + 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_on_device(self): + params = self.changes.api_params() + params['name'] = 'system-auth' + uri = "https://{0}:{1}/mgmt/tm/auth/tacacs".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, 403]: + if 'message' in response: + raise F5ModuleError(response['message']) + else: + raise F5ModuleError(resp.content) + + def update_on_device(self): + params = self.changes.api_params() + if not params: + return False + + uri = 'https://{0}:{1}/mgmt/tm/auth/tacacs/~Common~system-auth'.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) + return True + + def remove_from_device(self): + uri = "https://{0}:{1}/mgmt/tm/auth/tacacs/~Common~system-auth".format( + self.client.provider['server'], + self.client.provider['server_port'] + ) + resp = self.client.api.delete(uri) + if resp.status == 200: + return True + raise F5ModuleError(resp.content) + + def read_current_from_device(self): + uri = "https://{0}:{1}/mgmt/tm/auth/tacacs/~Common~system-auth".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) + response['auth_source'] = self.read_current_auth_source_from_device() + return self.get_api_parameters(params=response) + + +class ModuleManager(object): + def __init__(self, *args, **kwargs): + self.module = kwargs.get('module', None) + self.client = kwargs.get('client', None) + self.kwargs = kwargs + + def exec_module(self): + manager = self.get_manager(self.module.params['type']) + return manager.exec_module() + + def get_manager(self, type): + if type == 'tacacs': + return TacacsManager(**self.kwargs) + elif type == 'local': + return LocalManager(**self.kwargs) + else: + raise F5ModuleError( + "The provided 'type' is unknown." + ) + + +class ArgumentSpec(object): + def __init__(self): + self.supports_check_mode = True + argument_spec = dict( + type=dict( + required=True, + choices=['local', 'tacacs'] + ), + servers=dict(type='raw'), + secret=dict(no_log=True), + service_name=dict( + choices=[ + 'slip', 'ppp', 'arap', 'shell', 'tty-daemon', + 'connection', 'system', 'firewall' + ] + ), + protocol_name=dict( + choices=[ + 'lcp', 'ip', 'ipx', 'atalk', 'vines', 'lat', + 'xremote', 'tn3270', 'telnet', 'rlogin', 'pad', + 'vpdn', 'ftp', 'http', 'deccp', 'osicp', 'unknown' + ] + ), + authentication=dict( + choices=[ + 'use-first-server', + 'use-all-servers' + ] + ), + use_for_auth=dict(type='bool'), + update_secret=dict( + choices=['always', 'on_create'], + default='always' + ), + state=dict( + default='present', + choices=['present', 'absent'] + ), + + ) + 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, + ) + + try: + client = F5RestClient(**module.params) + mm = ModuleManager(module=module, client=client) + results = mm.exec_module() + exit_json(module, results, client) + except F5ModuleError as ex: + fail_json(module, ex, client) + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/network/f5/test_bigip_device_auth.py b/test/units/modules/network/f5/test_bigip_device_auth.py new file mode 100644 index 00000000000..1ab90684451 --- /dev/null +++ b/test/units/modules/network/f5/test_bigip_device_auth.py @@ -0,0 +1,131 @@ +# -*- 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 + +from nose.plugins.skip import SkipTest +if sys.version_info < (2, 7): + raise SkipTest("F5 Ansible modules require Python >= 2.7") + +from ansible.compat.tests import unittest +from ansible.compat.tests.mock import Mock +from ansible.compat.tests.mock import patch +from ansible.module_utils.basic import AnsibleModule + +try: + from library.modules.bigip_device_auth import TacacsApiParameters + from library.modules.bigip_device_auth import TacacsModuleParameters + from library.modules.bigip_device_auth import TacacsManager + from library.modules.bigip_device_auth import ModuleManager + from library.modules.bigip_device_auth import ArgumentSpec + from library.module_utils.network.f5.common import F5ModuleError + from test.unit.modules.utils import set_module_args +except ImportError: + try: + from ansible.modules.network.f5.bigip_sys_auth import TacacsApiParameters + from ansible.modules.network.f5.bigip_sys_auth import TacacsModuleParameters + from ansible.modules.network.f5.bigip_sys_auth import TacacsManager + from ansible.modules.network.f5.bigip_sys_auth import ModuleManager + from ansible.modules.network.f5.bigip_sys_auth import ArgumentSpec + from ansible.module_utils.network.f5.common import F5ModuleError + from units.modules.utils import set_module_args + except ImportError: + raise SkipTest("F5 Ansible modules require the f5-sdk Python library") + +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( + type="tacacs", + authentication="use-all-servers", + protocol_name="ip", + secret="$XXXXXXXXXXXXXXXXXXXX==", + servers=['10.10.10.10', '10.10.10.11'], + service_name="ppp", + use_for_auth=True, + update_secret="on_create", + ) + p = TacacsModuleParameters(params=args) + assert p.type == 'tacacs' + assert p.authentication == 'use-all-servers' + + def test_api_parameters(self): + args = load_fixture('load_tm_auth_tacacs_1.json') + p = TacacsApiParameters(params=args) + assert p.authentication == 'use-first-server' + assert p.protocol_name == 'ftp' + assert p.secret == 'secret' + assert p.servers == ['11.11.11.11'] + assert p.service_name == 'ppp' + + +@patch('ansible.module_utils.f5_utils.AnsibleF5Client._get_mgmt_root', + return_value=True) +class TestManager(unittest.TestCase): + + def setUp(self): + self.spec = ArgumentSpec() + + def test_create(self, *args): + set_module_args(dict( + type="tacacs", + authentication="use-all-servers", + protocol_name="ip", + secret="secret", + servers=['10.10.10.10', '10.10.10.11'], + service_name="ppp", + use_for_auth=True, + update_secret="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 + m1 = TacacsManager(module=module) + m1.exists = Mock(return_value=False) + m1.create_on_device = Mock(return_value=True) + m1.update_auth_source_on_device = Mock(return_value=True) + + mm = ModuleManager(module=module) + mm.get_manager = Mock(return_value=m1) + + results = mm.exec_module() + assert results['changed'] is True