Browse Source

Added modules `ldap_upsert` and `ldap_search`. Wrote documentation.

master
Dhruv Bansal 7 years ago
parent
commit
03bc81122d
7 changed files with 727 additions and 22 deletions
  1. +1
    -0
      .gitignore
  2. +254
    -20
      README.md
  3. +0
    -0
      __init__.py
  4. +1
    -1
      ldap_attr
  5. +1
    -1
      ldap_entry
  6. +231
    -0
      ldap_search
  7. +239
    -0
      ldap_upsert

+ 1
- 0
.gitignore View File

@@ -0,0 +1 @@
*.pyc

+ 254
- 20
README.md View File

@@ -1,27 +1,34 @@
# Ansible LDAP Modules

For whatever reasons, Ansible doesn't include modules to manipulate an
LDAP server in its [core](http://docs.ansible.com/ansible/modules_by_category.html).
LDAP server in its
[core](http://docs.ansible.com/ansible/modules_by_category.html).

Peter Sagerson implemented a pair of modules and has
[attempted to get them into Ansible](http://grokbase.com/t/gg/ansible-devel/148892tek3/ldap-modules),
but no luck yet. He's instead hosted his code on
[Bitbucket](https://bitbucket.org/psagers/ansible-ldap).

This repository is a fork of his original work (with some cosmetic
improvements) hosted on GitHub.
This repository is a fork of his original work with some additions
(and cosmetic improvements) hosted on GitHub.

# Installation

You'll need to clone this repository somewhere into your Ansible (use
a git submodule).

```
$ git clone https://github.com/unchained-capital/ansible-ldap-modules
```
The `python-ldap` library is required on whatever host is executing
these modules. This can be confusing if you use both
`local_action`-type tasks and tasks which run on remote hosts and both
need to talk do an LDAP server -- you'll need to install `python-ldap`
both locally on your controller as well as remotely on the hosts you
run the tasks on.

In addition, you'll need to clone this repository itself to your
controller and put it somewhere `ansible` can find it.


The `python-ldap` library is required on the Ansible controller node
for these modules to load. Install it with
## python-ldap Installation

Install it locally via the following commands:

```
$ cd ansible-ldap-modules
@@ -34,10 +41,38 @@ You may need to install some system dependencies first:
$ sudo apt-get install python-dev libsasl2-dev libldap2-dev libssl-dev
```

For remote hosts, this can be automated via Ansible:

```yaml
# in prepare_remote_ldap_host.yml, for example
- name: Install dependencies
apt: name="{{ item }}" state=present
with_items:
- python-pip
- libsasl2-dev
- libldap2-dev
- libssl-dev

- name: Upgrade pip
command: pip install --upgrade pip

- name: Install python-ldap
pip: name=python-ldap state=present
```

## Modules Installation

You'll need to clone this repository somewhere on your controller
machine so that `ansible` can find it.

```
$ git clone https://github.com/unchained-capital/ansible-ldap-modules
```

Once you have `python-ldap` installed, you'll need to link the
executable files in this repository into Ansible's library path. The
simplest way to do this is to create a `library` folder at the
top-level of your Ansible repository and create symlinks within it to
top-level of your playbook repository and create symlinks within it to
the module files in this repository:

```
@@ -52,14 +87,213 @@ or the `library` entry within the `defaults` section of your

# Usage

(copied from psager's [original README](https://bitbucket.org/psagers/ansible-ldap/src/ca4c0025358cdeba33e9ef0369af3430bf4812ad/README.md))
Remember, if you run these tasks locally, you need `python-ldap`
installed locally. If you run them on a remote machine, you need
`python-ldap` installed on that remote machine.

## Shared Behavior

All the modules in this repository share some behaviors. This section
describes those behaviors. The next section describes each module in
detail.

### Specifying an LDAP Server

Here is a simple example of creating an entry (more details on this
below):

```yaml
- hosts: server0
tasks:
- name: Ensure an LDAP entry exists for ou=People
ldap_entry:
dn: "ou=People,dc=example,dc=com"
ou: People
objectClass: organizationalUnit
description: Getting together and having a good time.
```

The target host of an LDAP operation is assumed to be the same host as
the Ansible task is executing so the above task would attempt to talk
to LDAP server running on `server0` listenting on port 389. This example:

```yaml
- hosts: server0
tasks:
- name: Ensure an LDAP entry exists for ou=People
ldap_entry:
server_uri: ldapi://server1/
dn: "ou=People,dc=example,dc=com"
ou: People
objectClass: organizationalUnit
description: Getting together and having a good time.
```

would target an LDAP server at `server1`.

### Choosing Credentials

Without any credentials (as in the above examples), the request will
be made via a SASL `EXTERNAL` bind (similar to `ldapadd ... -Y
EXTERNAL ...` on the command-line).

Credentials can be specified as well:


```yaml
- hosts: server0
tasks:
- name: Ensure an LDAP entry exists for ou=People
ldap_entry:
bind_dn: "cn=admin,dc=example,dc=com"
bind_pw: "password"
dn: "ou=People,dc=example,dc=com"
ou: People
objectClass: organizationalUnit
description: Getting together and having a good time.
```

## Modules

This repository provides four different modules:

This project contains a pair of Ansible modules for manipulating an
LDAP directory. `ldap_entry` can be used to ensure that an entire
entry exists and `ldap_attr` can be used to ensure the values of an
entry's attributes.
### ldap_entry

Ensures that an entry with a given `dn` exists/doesn't exist. If
missing, it creates it. It present it does nothing. In particular,
even if the entry has different attributes from those specified in
Ansible, `ldap_entry` does nothing. You can, however, use
`ldap_entry` to remove LDAP entries if you specify `state=absent`.

#### Creating an entry

```yaml
- name: Ensure an LDAP entry exists for ou=People
ldap_entry:
dn: "ou=People,dc=example,dc=com"
ou: People
objectClass: organizationalUnit
description: Getting together and having a good time.
```

If the `ou=People,dc=example,dc=com` entry has its `description` field
changed, this task will not update it. It's therefore best to only
specify the minimal number of fields required to successfully create
the entity, given its LDAP `objectClass` values and later use
`ldap_attr` to explicitly declare the expected state for each
attribute individually. Or use `ldap_upsert`.

#### Removing an entry

```yaml
- name: Ensure an LDAP entry exists for ou=People
ldap_entry:
dn: "ou=People,dc=example,dc=com"
state: absent
```

### ldap_attr

Ensures that an attribute with a given value exists/doesn't exist for
a given entry. If the entry does not exist, it throws an error. If
the doesn't exist, it creates it. If it exists but has a different
value, it updates it. You can use `ldap_attr` to remove LDAP
attributes if you specify `state=absent`.

#### Specifying an attribute exactly

Here's a simple example.

```yaml
- name: Ensure description is correct for ou=People
ldap_attr:
dn: "ou=People,dc=example,dc=com"
name: description
state: exact
values: Getting together and having a good time.

- name: Ensure members are correct for cn=Admins,ou=Groups
ldap_attr:
dn: "cn=Admins,ou=Groups,dc=example,dc=com"
name: member
state: exact
values:
- cn=joe,ou=People,dc=example,dc=com
- cn=bob,ou=People,dc=example,dc=com
```
This is a lot of work for many entries with many attributes. Consider
`ldap_upsert` in these cases.

#### Appending to an attribute

```yaml
- name: Ensure members are present in cn=Admins,ou=Groups
ldap_attr:
dn: "cn=Admins,ou=Groups,dc=example,dc=com"
name: member
state: present
values:
- cn=joe,ou=People,dc=example,dc=com
- cn=bob,ou=People,dc=example,dc=com
```

The user `cn=mike,ou=People,dc=example,dc=com` could still be a
`member` of `cn=Admins,ou=Groups,dc=example,dc=com` after this task
runs.

#### Removing an attribute

```yaml
- name: Ensure user's password is not set
ldap_attr:
dn: "cn=joe,ou=People,dc=example,dc=com"
state: absent
name: userPassword
```

### ldap_upsert

Ensures both that an entry exists as well as that its attributs have
particular values. You cannot use `ldap_upsert` to remove entries or
their attributes, but it useful when creating lots of entities with
their attributes.

```yaml

- name: Create a user
ldap_upsert:
dn: "uid=joe1234,ou=People,dc=example,dc=com"
objectClass:
- account
- posixAccount
cn: "Joe Smith"
gn: "Joe"
sn: "Smith"
uid: "joe1234"
homeDirectory: "/home/joe1234"
userPassword: "..."
```

### ldap_search

Perform an LDAP search. Useful in combination with Ansible's
`register` keyword.

This example performs a task for each LDAP user `account`:

```yaml
- name:
ldap_search:
base: "ou=People,dc=example,dc=com"
scope: onelevel
filter: "(objectClass=account)"
register: ldap_user_search

- name: View search results
debug: var=ldap_user_search

- name: Do something for each user
# ...
with_items: "{{ ldap_user_search.results }}"
```

Regrettably, Ansible does not have any sensible mechanism for
packaging and distributing third-party modules with rendered
documentation and runnable unit tests. The LDAP modules do have
complete documentation strings embedded.

+ 0
- 0
__init__.py View File


+ 1
- 1
ldap_attr View File

@@ -129,7 +129,7 @@ def main():
'values': dict(required=True),
'state': dict(default='present', choices=['present', 'absent', 'exact']),
'server_uri': dict(default='ldapi:///'),
'start_tls': dict(default='false', choices=BOOLEANS),
'start_tls': dict(default='false', choices=(BOOLEANS+['True', True, 'False', False])),
'bind_dn': dict(default=None),
'bind_pw': dict(default='', no_log=True),
},


+ 1
- 1
ldap_entry View File

@@ -95,7 +95,7 @@ def main():
'dn': dict(required=True),
'state': dict(default='present', choices=['present', 'absent']),
'server_uri': dict(default='ldapi:///'),
'start_tls': dict(default='false', choices=BOOLEANS),
'start_tls': dict(default='false', choices=(BOOLEANS+['True', True, 'False', False])),
'bind_dn': dict(default=None),
'bind_pw': dict(default='', no_log=True),
},


+ 231
- 0
ldap_search View File

@@ -0,0 +1,231 @@
#!/usr/bin/env python

from traceback import format_exc

import ldap
import ldap.modlist
import ldap.sasl


DOCUMENTATION = """
---
module: ldap_search
short_description: Return the results of an LDAP search.
description:
- Return the results of an LDAP search. Use in combination with
Ansible's 'register' statement.

notes: []
version_added: null
author: Dhruv Bansal
requirements:
- python-ldap
options:
base:
required: true
description:
- The base to search from.
scope:
required: false
choices: [base, onelevel, subordinate, children]
default: base
description:
- The LDAP scope to use when searching.
filter:
required: false
default: '(objectClass=*)'
description:
- The filter to apply to the search.
attrs:
required: false
default: none
description:
- A list of attrs to limit the results to. Can be an
actual list or just a comma-separated string.
schema:
required: false
default: false
description:
- Return the full attribute schema of entries, not their
attribute values. Overrides C(attrs) when given.
server_uri:
required: false
default: ldapi:///
description:
- A URI to the LDAP server. The default value lets the underlying
LDAP client library look for a UNIX domain socket in its default
location.
start_tls:
required: false
default: false
description:
- If true, we'll use the START_TLS LDAP extension.
bind_dn:
required: false
description:
- A DN to bind with. If this is omitted, we'll try a SASL bind with
the EXTERNAL mechanism. If this is blank, we'll use an anonymous
bind.
bind_pw:
required: false
description:
- The password to use with C(bind_dn).
"""


EXAMPLES = """
# Return all entries within the 'groups' organizational unit.
- ldap_search: base='ou=groups,dc=example,dc=com'
register: ldap_groups
sudo: true

# Return GIDs for all groups
- ldap_entry: base='ou=groups,dc=example,dc=com' scope=onelevel attrs="gidNumber"
register: ldap_group_gids
sudo: true
"""

def main():
module = AnsibleModule(
argument_spec={
'base': dict(required=True),
'scope': dict(default='base', choices=['base', 'onelevel', 'subordinate', 'children']),
'filter': dict(default='(objectClass=*)'),
'attrs': dict(default=None),
'schema': dict(default=False, choices=(BOOLEANS+['True', True, 'False', False])),
'server_uri': dict(default='ldapi:///'),
'start_tls': dict(default='false', choices=(BOOLEANS+['True', True, 'False', False])),
'bind_dn': dict(default=None),
'bind_pw': dict(default='', no_log=True),
},
check_invalid_arguments=False,
supports_check_mode=False,
)

try:
LdapSearch(module).main()
except ldap.LDAPError, e:
module.fail_json(msg=str(e), exc=format_exc())


class LdapSearch(object):
_connection = None

def __init__(self, module):
self.module = module

# python-ldap doesn't understand unicode strings. Parameters that are
# just going to get passed to python-ldap APIs are stored as utf-8.
self.base = self._utf8_param('base')
self.filterstr = self._utf8_param('filter')
self.server_uri = self.module.params['server_uri']
self.start_tls = self.module.boolean(self.module.params['start_tls'])
self.bind_dn = self._utf8_param('bind_dn')
self.bind_pw = self._utf8_param('bind_pw')
self.attrlist = []

self._load_scope()
self._load_attrs()
self._load_schema()

# if (self.state == 'present') and ('objectClass' not in self.attrs):
# self.module.fail_json(msg="When state=present, at least one objectClass must be provided")

def _utf8_param(self, name):
return self._force_utf8(self.module.params[name])

def _load_schema(self):
self.schema = self.module.boolean(self.module.params['schema'])
if self.schema:
self.attrsonly = 1
else:
self.attrsonly = 0

def _load_scope(self):
scope = self.module.params['scope']
if scope == 'base': self.scope = ldap.SCOPE_BASE
elif scope == 'onelevel': self.scope = ldap.SCOPE_ONELEVEL
elif scope == 'subordinate': self.scope = ldap.SCOPE_SUBORDINATE
elif scope == 'children': self.scope = ldap.SCOPE_SUBTREE
else:
self.module.fail_json(msg="scope must be one of: base, onelevel, subordinate, children")

def _load_attrs(self):
if self.module.params['attrs'] is None:
self.attrlist = None
else:
attrs = self._load_attr_values(self.module.params['attrs'])
if len(attrs) > 0:
self.attrlist = attrs
else:
self.attrlist = None

def _load_attr_values(self, raw):
if isinstance(raw, basestring):
values = raw.split(',')
else:
values = raw

if not (isinstance(values, list) and all(isinstance(value, basestring) for value in values)):
self.module.fail_json(msg="attrs must be a string or list of strings.")

return map(self._force_utf8, values)

def _force_utf8(self, value):
""" If value is unicode, encode to utf-8. """
if isinstance(value, unicode):
value = value.encode('utf-8')

return value

def main(self):
results = self.perform_search()
self.module.exit_json(changed=True, results=results)

def perform_search(self):
try:
results = self.connection.search_s(self.base, self.scope, filterstr=self.filterstr, attrlist=self.attrlist, attrsonly=self.attrsonly)
if self.schema:
return [dict(dn=result[0],attrs=result[1].keys()) for result in results]
else:
return [self._extract_entry(result[0], result[1]) for result in results]
except ldap.NO_SUCH_OBJECT:
self.module.fail_json(msg="Base not found: {}".format(self.base))

def _extract_entry(self, dn, attrs):
extracted = {'dn': dn}
for attr, val in attrs.iteritems():
if len(val) == 1:
extracted[attr] = val[0]
else:
extracted[attr] = val
return extracted

#
# LDAP Connection
#

@property
def connection(self):
""" An authenticated connection to the LDAP server (cached). """
if self._connection is None:
self._connection = self._connect_to_ldap()

return self._connection

def _connect_to_ldap(self):
connection = ldap.initialize(self.server_uri)

if self.start_tls:
connection.start_tls_s()

if self.bind_dn is not None:
connection.simple_bind_s(self.bind_dn, self.bind_pw)
else:
connection.sasl_interactive_bind_s('', ldap.sasl.external())

return connection


from ansible.module_utils.basic import * # noqa
main()

+ 239
- 0
ldap_upsert View File

@@ -0,0 +1,239 @@
#!/usr/bin/env python

from traceback import format_exc

import ldap
import ldap.modlist
import ldap.sasl

DOCUMENTATION = """
---
module: ldap_upsert
short_description: Insert or update an LDAP entry.
description:
- Insert or update an LDAP entry. Existing entries with matching
C(dn) will have their attributes updated. Otherwise a new entry
will be created with the given attributes. This module cannot
delete entries or remove attributes, see M(ldap_entry) and
M(ldap_attr), respectively.
notes: []
version_added: null
author: Peter Sagerson
requirements:
- python-ldap
options:
dn:
required: true
description:
- The DN of the entry to insert or update.
objectClass:
required: true
description:
- Must be a list of objectClass values to use when
creating the entry. It can either be a string containing
a comma-separated list of values, or an actual list of
strings. When updating, these values are not used.
'...':
required: false
description:
- All additional arguments are taken to be LDAP attribute
names like C(objectClass), with similar lists of values.
If the entry exists, these attributes will be updated if
necessary. Otherwise the newly created entry will have
these attributes.
server_uri:
required: false
default: ldapi:///
description:
- A URI to the LDAP server. The default value lets the underlying
LDAP client library look for a UNIX domain socket in its default
location.
start_tls:
required: false
default: false
description:
- If true, we'll use the START_TLS LDAP extension.
bind_dn:
required: false
description:
- A DN to bind with. If this is omitted, we'll try a SASL bind with
the EXTERNAL mechanism. If this is blank, we'll use an anonymous
bind.
bind_pw:
required: false
description:
- The password to use with C(bind_dn).
"""


EXAMPLES = """
# Make sure we have a parent entry for users.
- ldap_entry: dn='ou=users,dc=example,dc=com' objectClass=organizationalUnit description="My Org Unit"
sudo: true

# Make sure we have an admin user.
- ldap_entry:
dn: 'cn=admin,dc=example,dc=com'
objectClass: simpleSecurityObject,organizationalRole
description: An LDAP administrator
userPassword: '{SSHA}pedsA5Y9wHbZ5R90pRdxTEZmn6qvPdzm'
sudo: true
"""

def main():
module = AnsibleModule(
argument_spec={
'dn': dict(required=True),
'server_uri': dict(default='ldapi:///'),
'start_tls': dict(default='false', choices=(BOOLEANS+['True', True, 'False', False])),
'bind_dn': dict(default=None),
'bind_pw': dict(default='', no_log=True),
},
check_invalid_arguments=False,
supports_check_mode=False,
)

try:
LdapUpsert(module).main()
except ldap.LDAPError, e:
module.fail_json(msg=str(e), exc=format_exc())


class LdapUpsert(object):

def __init__(self, module):
self.module = module

self.server_uri = self.module.params['server_uri']
self.start_tls = self._boolean_param('start_tls')
self.bind_dn = self._utf8_param('bind_dn')
self.bind_pw = self._utf8_param('bind_pw')
self.dn = self._utf8_param('dn')
self._load_attrs()

if 'objectClass' not in self.attrs:
self.module.fail_json(msg="At least one objectClass must be provided")

def _boolean_param(self, name):
return self.module.boolean(self.module.params[name])
def _utf8_param(self, name):
return self._force_utf8(self.module.params[name])

def _load_attr_values(self, name, raw):
if isinstance(raw, basestring):
values = raw.split(',')
else:
values = raw

if not (isinstance(values, list) and all(isinstance(value, basestring) for value in values)):
self.module.fail_json(msg="{} must be a string or list of strings.".format(name))

return map(self._force_utf8, values)
def _force_utf8(self, value):
""" If value is unicode, encode to utf-8. """
if isinstance(value, unicode):
value = value.encode('utf-8')

return value
def _load_attrs(self):
self.attrs = {}
for name, raw in self.module.params.iteritems():
if name not in self.module.argument_spec:
self.attrs[name] = self._load_attr_values(name, raw)

def main(self):
if self.entry_exists():
results = self.update_entry()
self.module.exit_json(**results)
else:
insert_result = self.insert_entry()
self.module.exit_json(changed=True, results=[insert_result])

def entry_exists(self):
try:
results = self.connection.search_s(self.dn, ldap.SCOPE_BASE)
for result in results:
if result[0] == self.dn:
return True
except ldap.NO_SUCH_OBJECT:
return False
def insert_entry(self):
modlist = ldap.modlist.addModlist(self.attrs)
result = self.connection.add_s(self.dn, modlist)
return result

def update_entry(self):
results = []
for attr, value in self.attrs.iteritems():
if attr == 'objectClass': continue
value = self._extract_value(value)
check = self._attribute_value_check(attr, value)
if check is False:
op = ldap.MOD_REPLACE
elif check is None:
op = ldap.MOD_ADD
else:
op = None # Nothing to see here...
if op is not None:
result = self.connection.modify_s(self.dn, [(op, attr, value)])
results.append(result)
if len(results) == 0:
return dict(changed=False)
else:
return dict(changed=True, results=results)

def _attribute_value_check(self, attr, value):
try:
return bool(self.connection.compare_s(self.dn, attr, value))
except ldap.NO_SUCH_ATTRIBUTE:
return None

def _extract_value(self, values):
if isinstance(values, basestring):
if values == '':
values = []
else:
values = [values]

if not (isinstance(values, list) and all(isinstance(value, basestring) for value in values)):
self.module.fail_json(msg="Attribute values must be strings or lists of strings.")

values = map(self._force_utf8, values)
if len(values) == 1:
return values[0]
else:
return values

#
# LDAP Connection
#
_connection = None

@property
def connection(self):
""" An authenticated connection to the LDAP server (cached). """
if self._connection is None:
self._connection = self._connect_to_ldap()

return self._connection
def _connect_to_ldap(self):
connection = ldap.initialize(self.server_uri)

if self.start_tls:
connection.start_tls_s()

if self.bind_dn is not None:
connection.simple_bind_s(self.bind_dn, self.bind_pw)
else:
connection.sasl_interactive_bind_s('', ldap.sasl.external())

return connection
from ansible.module_utils.basic import * # noqa
main()

Loading…
Cancel
Save