1#!/usr/bin/python
2# -*- coding: utf-8 -*-
3#
4# Copyright: (c) 2018, F5 Networks Inc.
5# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
6
7from __future__ import absolute_import, division, print_function
8__metaclass__ = type
9
10
11ANSIBLE_METADATA = {'metadata_version': '1.1',
12                    'status': ['preview'],
13                    'supported_by': 'certified'}
14
15DOCUMENTATION = r'''
16---
17module: bigip_device_auth_ldap
18short_description: Manage LDAP device authentication settings on BIG-IP
19description:
20  - Manage LDAP device authentication settings on BIG-IP.
21version_added: 2.8
22options:
23  servers:
24    description:
25      - Specifies the LDAP servers that the system must use to obtain
26        authentication information. You must specify a server when you
27        create an LDAP configuration object.
28    type: list
29  port:
30    description:
31      - Specifies the port that the system uses for access to the remote host server.
32      - When configuring LDAP device authentication for the first time, if this parameter
33        is not specified, the default port is C(389).
34    type: int
35  remote_directory_tree:
36    description:
37      - Specifies the file location (tree) of the user authentication database on the
38        server.
39    type: str
40  scope:
41    description:
42      - Specifies the level of the remote Active Directory or LDAP directory that the
43        system should search for the user authentication.
44    type: str
45    choices:
46      - sub
47      - one
48      - base
49  bind_dn:
50    description:
51      - Specifies the distinguished name for the Active Directory or LDAP server user
52        ID.
53      - The BIG-IP client authentication module does not support Active Directory or
54        LDAP servers that do not perform bind referral when authenticating referred
55        accounts.
56      - Therefore, if you plan to use Active Directory or LDAP as your authentication
57        source and want to use referred accounts, make sure your servers perform bind
58        referral.
59    type: str
60  bind_password:
61    description:
62      - Specifies a password for the Active Directory or LDAP server user ID.
63    type: str
64  user_template:
65    description:
66      - Specifies the distinguished name of the user who is logging on.
67      - You specify the template as a variable that the system replaces with user-specific
68        information during the logon attempt.
69      - For example, you could specify a user template such as C(%s@siterequest.com) or
70        C(uxml:id=%s,ou=people,dc=siterequest,dc=com).
71      - When a user attempts to log on, the system replaces C(%s) with the name the user
72        specified in the Basic Authentication dialog box, and passes that as the
73        distinguished name for the bind operation.
74      - The system passes the associated password as the password for the bind operation.
75      - This field can contain only one C(%s) and cannot contain any other format
76        specifiers.
77    type: str
78  check_member_attr:
79    description:
80      - Checks the user's member attribute in the remote LDAP or AD group.
81    type: bool
82  ssl:
83    description:
84      - Specifies whether the system uses an SSL port to communicate with the LDAP server.
85    type: str
86    choices:
87      - "yes"
88      - "no"
89      - start-tls
90  ca_cert:
91    description:
92      - Specifies the name of an SSL certificate from a certificate authority (CA).
93      - To remove this value, use the reserved value C(none).
94    type: str
95    aliases: [ ssl_ca_cert ]
96  client_key:
97    description:
98      - Specifies the name of an SSL client key.
99      - To remove this value, use the reserved value C(none).
100    type: str
101    aliases: [ ssl_client_key ]
102  client_cert:
103    description:
104      - Specifies the name of an SSL client certificate.
105      - To remove this value, use the reserved value C(none).
106    type: str
107    aliases: [ ssl_client_cert ]
108  validate_certs:
109    description:
110      - Specifies whether the system checks an SSL peer, as a result of which the
111        system requires and verifies the server certificate.
112    type: bool
113    aliases: [ ssl_check_peer ]
114  login_ldap_attr:
115    description:
116      - Specifies the LDAP directory attribute containing the local user name that is
117        associated with the selected directory entry.
118      - When configuring LDAP device authentication for the first time, if this parameter
119        is not specified, the default port is C(samaccountname).
120    type: str
121  fallback_to_local:
122    description:
123      - Specifies that the system uses the Local authentication method if the remote
124        authentication method is not available.
125    type: bool
126  state:
127    description:
128      - When C(present), ensures the device authentication method exists.
129      - When C(absent), ensures the device authentication method does not exist.
130    type: str
131    choices:
132      - present
133      - absent
134    default: present
135  update_password:
136    description:
137      - C(always) will always update the C(bind_password).
138      - C(on_create) will only set the C(bind_password) for newly created authentication
139        mechanisms.
140    type: str
141    choices:
142      - always
143      - on_create
144    default: always
145extends_documentation_fragment: f5
146author:
147  - Tim Rupp (@caphrim007)
148  - Wojciech Wypior (@wojtek0806)
149'''
150
151EXAMPLES = r'''
152- name: Create an LDAP authentication object
153  bigip_device_auth_ldap:
154    name: foo
155    provider:
156      password: secret
157      server: lb.mydomain.com
158      user: admin
159  delegate_to: localhost
160'''
161
162RETURN = r'''
163servers:
164  description: LDAP servers used by the system to obtain authentication information.
165  returned: changed
166  type: list
167  sample: ['192.168.1.1', '192.168.1.2']
168port:
169  description: The port that the system uses for access to the remote LDAP server.
170  returned: changed
171  type: int
172  sample: 389
173remote_directory_tree:
174  description: File location (tree) of the user authentication database on the server.
175  returned: changed
176  type: str
177  sample: "CN=Users,DC=FOOBAR,DC=LOCAL"
178scope:
179  description: The level of the remote Active Directory or LDAP directory searched for user authentication.
180  returned: changed
181  type: str
182  sample: base
183bind_dn:
184  description: The distinguished name for the Active Directory or LDAP server user ID.
185  returned: changed
186  type: str
187  sample: "user@foobar.local"
188user_template:
189  description: The distinguished name of the user who is logging on.
190  returned: changed
191  type: str
192  sample: "uid=%s,ou=people,dc=foobar,dc=local"
193check_member_attr:
194  description: The user's member attribute in the remote LDAP or AD group.
195  returned: changed
196  type: bool
197  sample: yes
198ssl:
199  description: Specifies whether the system uses an SSL port to communicate with the LDAP server.
200  returned: changed
201  type: str
202  sample: start-tls
203ca_cert:
204  description: The name of an SSL certificate from a certificate authority.
205  returned: changed
206  type: str
207  sample: My-Trusted-CA-Bundle.crt
208client_key:
209  description: The name of an SSL client key.
210  returned: changed
211  type: str
212  sample: MyKey.key
213client_cert:
214  description: The name of an SSL client certificate.
215  returned: changed
216  type: str
217  sample: MyCert.crt
218validate_certs:
219  description: Indicates if the system checks an SSL peer.
220  returned: changed
221  type: bool
222  sample: yes
223login_ldap_attr:
224  description: The LDAP directory attribute containing the local user name associated with the selected directory entry.
225  returned: changed
226  type: str
227  sample: samaccountname
228fallback_to_local:
229  description: Specifies that the system uses the Local authentication method as fallback
230  returned: changed
231  type: bool
232  sample: yes
233'''
234
235from ansible.module_utils.basic import AnsibleModule
236
237try:
238    from library.module_utils.network.f5.bigip import F5RestClient
239    from library.module_utils.network.f5.common import F5ModuleError
240    from library.module_utils.network.f5.common import AnsibleF5Parameters
241    from library.module_utils.network.f5.common import fq_name
242    from library.module_utils.network.f5.common import transform_name
243    from library.module_utils.network.f5.common import f5_argument_spec
244    from library.module_utils.network.f5.common import flatten_boolean
245    from library.module_utils.network.f5.compare import cmp_str_with_none
246except ImportError:
247    from ansible.module_utils.network.f5.bigip import F5RestClient
248    from ansible.module_utils.network.f5.common import F5ModuleError
249    from ansible.module_utils.network.f5.common import AnsibleF5Parameters
250    from ansible.module_utils.network.f5.common import fq_name
251    from ansible.module_utils.network.f5.common import transform_name
252    from ansible.module_utils.network.f5.common import f5_argument_spec
253    from ansible.module_utils.network.f5.common import flatten_boolean
254    from ansible.module_utils.network.f5.compare import cmp_str_with_none
255
256
257class Parameters(AnsibleF5Parameters):
258    api_map = {
259        'bindDn': 'bind_dn',
260        'bindPw': 'bind_password',
261        'userTemplate': 'user_template',
262        'fallback': 'fallback_to_local',
263        'loginAttribute': 'login_ldap_attr',
264        'sslCheckPeer': 'validate_certs',
265        'sslClientCert': 'client_cert',
266        'sslClientKey': 'client_key',
267        'sslCaCertFile': 'ca_cert',
268        'checkRolesGroup': 'check_member_attr',
269        'searchBaseDn': 'remote_directory_tree',
270    }
271
272    api_attributes = [
273        'bindDn',
274        'bindPw',
275        'checkRolesGroup',
276        'loginAttribute',
277        'port',
278        'scope',
279        'searchBaseDn',
280        'servers',
281        'ssl',
282        'sslCaCertFile',
283        'sslCheckPeer',
284        'sslClientCert',
285        'sslClientKey',
286        'userTemplate',
287    ]
288
289    returnables = [
290        'bind_dn',
291        'bind_password',
292        'check_member_attr',
293        'fallback_to_local',
294        'login_ldap_attr',
295        'port',
296        'remote_directory_tree',
297        'scope',
298        'servers',
299        'ssl',
300        'ca_cert',
301        'validate_certs',
302        'client_cert',
303        'client_key',
304        'user_template',
305    ]
306
307    updatables = [
308        'bind_dn',
309        'bind_password',
310        'check_member_attr',
311        'fallback_to_local',
312        'login_ldap_attr',
313        'port',
314        'remote_directory_tree',
315        'scope',
316        'servers',
317        'ssl',
318        'ssl_ca_cert',
319        'ssl_check_peer',
320        'ssl_client_cert',
321        'ssl_client_key',
322        'user_template',
323    ]
324
325    @property
326    def ssl_ca_cert(self):
327        if self._values['ssl_ca_cert'] is None:
328            return None
329        elif self._values['ssl_ca_cert'] in ['none', '']:
330            return ''
331        return fq_name(self.partition, self._values['ssl_ca_cert'])
332
333    @property
334    def ssl_client_key(self):
335        if self._values['ssl_client_key'] is None:
336            return None
337        elif self._values['ssl_client_key'] in ['none', '']:
338            return ''
339        return fq_name(self.partition, self._values['ssl_client_key'])
340
341    @property
342    def ssl_client_cert(self):
343        if self._values['ssl_client_cert'] is None:
344            return None
345        elif self._values['ssl_client_cert'] in ['none', '']:
346            return ''
347        return fq_name(self.partition, self._values['ssl_client_cert'])
348
349    @property
350    def ssl_check_peer(self):
351        return flatten_boolean(self._values['ssl_check_peer'])
352
353    @property
354    def fallback_to_local(self):
355        return flatten_boolean(self._values['fallback_to_local'])
356
357    @property
358    def check_member_attr(self):
359        return flatten_boolean(self._values['check_member_attr'])
360
361    @property
362    def login_ldap_attr(self):
363        if self._values['login_ldap_attr'] is None:
364            return None
365        elif self._values['login_ldap_attr'] in ['none', '']:
366            return ''
367        return self._values['login_ldap_attr']
368
369    @property
370    def user_template(self):
371        if self._values['user_template'] is None:
372            return None
373        elif self._values['user_template'] in ['none', '']:
374            return ''
375        return self._values['user_template']
376
377    @property
378    def ssl(self):
379        if self._values['ssl'] is None:
380            return None
381        elif self._values['ssl'] == 'start-tls':
382            return 'start-tls'
383        return flatten_boolean(self._values['ssl'])
384
385
386class ApiParameters(Parameters):
387    pass
388
389
390class ModuleParameters(Parameters):
391    pass
392
393
394class Changes(Parameters):
395    def to_return(self):
396        result = {}
397        try:
398            for returnable in self.returnables:
399                result[returnable] = getattr(self, returnable)
400            result = self._filter_params(result)
401        except Exception:
402            pass
403        return result
404
405
406class UsableChanges(Changes):
407    @property
408    def ssl_check_peer(self):
409        if self._values['ssl_check_peer'] is None:
410            return None
411        elif self._values['ssl_check_peer'] == 'yes':
412            return 'enabled'
413        return 'disabled'
414
415    @property
416    def fallback_to_local(self):
417        if self._values['fallback_to_local'] is None:
418            return None
419        elif self._values['fallback_to_local'] == 'yes':
420            return 'true'
421        return 'false'
422
423    @property
424    def check_member_attr(self):
425        if self._values['check_member_attr'] is None:
426            return None
427        elif self._values['check_member_attr'] == 'yes':
428            return 'enabled'
429        return 'disabled'
430
431    @property
432    def ssl(self):
433        if self._values['ssl'] is None:
434            return None
435        elif self._values['ssl'] == 'start-tls':
436            return 'start-tls'
437        elif self._values['ssl'] == 'yes':
438            return 'enabled'
439        return 'disabled'
440
441
442class ReportableChanges(Changes):
443    @property
444    def bind_password(self):
445        return None
446
447    @property
448    def ssl_check_peer(self):
449        return flatten_boolean(self._values['ssl_check_peer'])
450
451    @property
452    def check_member_attr(self):
453        return flatten_boolean(self._values['check_member_attr'])
454
455    @property
456    def ssl(self):
457        if self._values['ssl'] is None:
458            return None
459        elif self._values['ssl'] == 'start-tls':
460            return 'start-tls'
461        return flatten_boolean(self._values['ssl'])
462
463
464class Difference(object):
465    def __init__(self, want, have=None):
466        self.want = want
467        self.have = have
468
469    def compare(self, param):
470        try:
471            result = getattr(self, param)
472            return result
473        except AttributeError:
474            return self.__default(param)
475
476    def __default(self, param):
477        attr1 = getattr(self.want, param)
478        try:
479            attr2 = getattr(self.have, param)
480            if attr1 != attr2:
481                return attr1
482        except AttributeError:
483            return attr1
484
485    @property
486    def login_ldap_attr(self):
487        return cmp_str_with_none(self.want.login_ldap_attr, self.have.login_ldap_attr)
488
489    @property
490    def user_template(self):
491        return cmp_str_with_none(self.want.user_template, self.have.user_template)
492
493    @property
494    def ssl_ca_cert(self):
495        return cmp_str_with_none(self.want.ssl_ca_cert, self.have.ssl_ca_cert)
496
497    @property
498    def ssl_client_key(self):
499        return cmp_str_with_none(self.want.ssl_client_key, self.have.ssl_client_key)
500
501    @property
502    def ssl_client_cert(self):
503        return cmp_str_with_none(self.want.ssl_client_cert, self.have.ssl_client_cert)
504
505    @property
506    def bind_password(self):
507        if self.want.bind_password != self.have.bind_password and self.want.update_password == 'always':
508            return self.want.bind_password
509
510
511class ModuleManager(object):
512    def __init__(self, *args, **kwargs):
513        self.module = kwargs.get('module', None)
514        self.client = F5RestClient(**self.module.params)
515        self.want = ModuleParameters(params=self.module.params)
516        self.have = ApiParameters()
517        self.changes = UsableChanges()
518
519    def _set_changed_options(self):
520        changed = {}
521        for key in Parameters.returnables:
522            if getattr(self.want, key) is not None:
523                changed[key] = getattr(self.want, key)
524        if changed:
525            self.changes = UsableChanges(params=changed)
526
527    def _update_changed_options(self):
528        diff = Difference(self.want, self.have)
529        updatables = Parameters.updatables
530        changed = dict()
531        for k in updatables:
532            change = diff.compare(k)
533            if change is None:
534                continue
535            else:
536                if isinstance(change, dict):
537                    changed.update(change)
538                else:
539                    changed[k] = change
540        if changed:
541            self.changes = UsableChanges(params=changed)
542            return True
543        return False
544
545    def _announce_deprecations(self, result):
546        warnings = result.pop('__warnings', [])
547        for warning in warnings:
548            self.client.module.deprecate(
549                msg=warning['msg'],
550                version=warning['version']
551            )
552
553    def update_auth_source_on_device(self, source):
554        """Set the system auth source.
555
556        Configuring the authentication source is only one step in the process of setting
557        up an auth source. The other step is to inform the system of the auth source
558        you want to use.
559
560        This method is used for situations where
561
562        * The ``use_for_auth`` parameter is set to ``yes``
563        * The ``use_for_auth`` parameter is set to ``no``
564        * The ``state`` parameter is set to ``absent``
565
566        When ``state`` equal to ``absent``, before you can delete the TACACS+ configuration,
567        you must set the system auth to "something else". The system ships with a system
568        auth called "local", so this is the logical "something else" to use.
569
570        When ``use_for_auth`` is no, the same situation applies as when ``state`` equal
571        to ``absent`` is done above.
572
573        When ``use_for_auth`` is ``yes``, this method will set the current system auth
574        state to TACACS+.
575
576        Arguments:
577            source (string): The source that you want to set on the device.
578        """
579        params = dict(
580            type=source
581        )
582        uri = 'https://{0}:{1}/mgmt/tm/auth/source/'.format(
583            self.client.provider['server'],
584            self.client.provider['server_port']
585        )
586
587        resp = self.client.api.patch(uri, json=params)
588        try:
589            response = resp.json()
590        except ValueError as ex:
591            raise F5ModuleError(str(ex))
592
593        if 'code' in response and response['code'] == 400:
594            if 'message' in response:
595                raise F5ModuleError(response['message'])
596            else:
597                raise F5ModuleError(resp.content)
598
599    def update_fallback_on_device(self, fallback):
600        params = dict(
601            fallback=fallback
602        )
603        uri = 'https://{0}:{1}/mgmt/tm/auth/source/'.format(
604            self.client.provider['server'],
605            self.client.provider['server_port']
606        )
607
608        resp = self.client.api.patch(uri, json=params)
609        try:
610            response = resp.json()
611        except ValueError as ex:
612            raise F5ModuleError(str(ex))
613
614        if 'code' in response and response['code'] == 400:
615            if 'message' in response:
616                raise F5ModuleError(response['message'])
617            else:
618                raise F5ModuleError(resp.content)
619
620    def exec_module(self):
621        changed = False
622        result = dict()
623        state = self.want.state
624
625        if state == "present":
626            changed = self.present()
627        elif state == "absent":
628            changed = self.absent()
629
630        reportable = ReportableChanges(params=self.changes.to_return())
631        changes = reportable.to_return()
632        result.update(**changes)
633        result.update(dict(changed=changed))
634        self._announce_deprecations(result)
635        return result
636
637    def present(self):
638        if self.exists():
639            return self.update()
640        else:
641            return self.create()
642
643    def absent(self):
644        if self.exists():
645            return self.remove()
646        return False
647
648    def should_update(self):
649        result = self._update_changed_options()
650        if result:
651            return True
652        return False
653
654    def update(self):
655        self.have = self.read_current_from_device()
656        if not self.should_update():
657            return False
658        if self.module.check_mode:
659            return True
660        self.update_on_device()
661        if self.want.fallback_to_local == 'yes':
662            self.update_fallback_on_device('true')
663        elif self.want.fallback_to_local == 'no':
664            self.update_fallback_on_device('false')
665        return True
666
667    def remove(self):
668        if self.module.check_mode:
669            return True
670        self.update_auth_source_on_device('local')
671        self.remove_from_device()
672        if self.exists():
673            raise F5ModuleError("Failed to delete the resource.")
674        return True
675
676    def create(self):
677        self._set_changed_options()
678        if self.module.check_mode:
679            return True
680        self.create_on_device()
681        if self.want.fallback_to_local == 'yes':
682            self.update_fallback_on_device('true')
683        elif self.want.fallback_to_local == 'no':
684            self.update_fallback_on_device('false')
685        return True
686
687    def exists(self):
688        uri = "https://{0}:{1}/mgmt/tm/auth/ldap/{2}".format(
689            self.client.provider['server'],
690            self.client.provider['server_port'],
691            transform_name('Common', 'system-auth')
692        )
693        resp = self.client.api.get(uri)
694        try:
695            response = resp.json()
696        except ValueError:
697            return False
698        if resp.status == 404 or 'code' in response and response['code'] == 404:
699            return False
700        return True
701
702    def create_on_device(self):
703        params = self.changes.api_params()
704        params['name'] = 'system-auth'
705        params['partition'] = 'Common'
706        uri = "https://{0}:{1}/mgmt/tm/auth/ldap/".format(
707            self.client.provider['server'],
708            self.client.provider['server_port'],
709        )
710        resp = self.client.api.post(uri, json=params)
711        try:
712            response = resp.json()
713        except ValueError as ex:
714            raise F5ModuleError(str(ex))
715
716        if 'code' in response and response['code'] in [400, 409]:
717            if 'message' in response:
718                raise F5ModuleError(response['message'])
719            else:
720                raise F5ModuleError(resp.content)
721        return True
722
723    def update_on_device(self):
724        params = self.changes.api_params()
725        if not params:
726            return
727        uri = "https://{0}:{1}/mgmt/tm/auth/ldap/{2}".format(
728            self.client.provider['server'],
729            self.client.provider['server_port'],
730            transform_name('Common', 'system-auth')
731        )
732        resp = self.client.api.patch(uri, json=params)
733        try:
734            response = resp.json()
735        except ValueError as ex:
736            raise F5ModuleError(str(ex))
737
738        if 'code' in response and response['code'] == 400:
739            if 'message' in response:
740                raise F5ModuleError(response['message'])
741            else:
742                raise F5ModuleError(resp.content)
743
744    def remove_from_device(self):
745        uri = "https://{0}:{1}/mgmt/tm/auth/ldap/{2}".format(
746            self.client.provider['server'],
747            self.client.provider['server_port'],
748            transform_name('Common', 'system-auth')
749        )
750        response = self.client.api.delete(uri)
751        if response.status == 200:
752            return True
753        raise F5ModuleError(response.content)
754
755    def read_current_from_device(self):
756        uri = "https://{0}:{1}/mgmt/tm/auth/ldap/{2}".format(
757            self.client.provider['server'],
758            self.client.provider['server_port'],
759            transform_name('Common', 'system-auth')
760        )
761        resp = self.client.api.get(uri)
762        try:
763            response = resp.json()
764        except ValueError as ex:
765            raise F5ModuleError(str(ex))
766
767        if 'code' in response and response['code'] == 400:
768            if 'message' in response:
769                raise F5ModuleError(response['message'])
770            else:
771                raise F5ModuleError(resp.content)
772        result = ApiParameters(params=response)
773
774        uri = 'https://{0}:{1}/mgmt/tm/auth/source/'.format(
775            self.client.provider['server'],
776            self.client.provider['server_port']
777        )
778        resp = self.client.api.get(uri)
779        try:
780            response = resp.json()
781        except ValueError as ex:
782            raise F5ModuleError(str(ex))
783
784        if 'code' in response and response['code'] == 400:
785            if 'message' in response:
786                raise F5ModuleError(response['message'])
787            else:
788                raise F5ModuleError(resp.content)
789        result.update({'fallback': response['fallback']})
790        return result
791
792
793class ArgumentSpec(object):
794    def __init__(self):
795        self.supports_check_mode = True
796        argument_spec = dict(
797            servers=dict(type='list'),
798            port=dict(type='int'),
799            remote_directory_tree=dict(),
800            scope=dict(
801                choices=['sub', 'one', 'base']
802            ),
803            bind_dn=dict(),
804            bind_password=dict(no_log=True),
805            user_template=dict(),
806            check_member_attr=dict(type='bool'),
807            ssl=dict(
808                choices=['yes', 'no', 'start-tls']
809            ),
810            ca_cert=dict(aliases=['ssl_ca_cert']),
811            client_key=dict(aliases=['ssl_client_key']),
812            client_cert=dict(aliases=['ssl_client_cert']),
813            validate_certs=dict(type='bool', aliases=['ssl_check_peer']),
814            login_ldap_attr=dict(),
815            fallback_to_local=dict(type='bool'),
816            update_password=dict(
817                default='always',
818                choices=['always', 'on_create']
819            ),
820            state=dict(default='present', choices=['absent', 'present']),
821        )
822        self.argument_spec = {}
823        self.argument_spec.update(f5_argument_spec)
824        self.argument_spec.update(argument_spec)
825
826
827def main():
828    spec = ArgumentSpec()
829
830    module = AnsibleModule(
831        argument_spec=spec.argument_spec,
832        supports_check_mode=spec.supports_check_mode,
833    )
834
835    try:
836        mm = ModuleManager(module=module)
837        results = mm.exec_module()
838        module.exit_json(**results)
839    except F5ModuleError as ex:
840        module.fail_json(msg=str(ex))
841
842
843if __name__ == '__main__':
844    main()
845