Add new role, 'debops.ldap'

The 'debops.ldap' role sets up the system-wide LDAP configuration, and
can be used to access and modify the LDAP directory via Ansible
inventory or other Ansible roles/playbooks.
parent 82f85eeb
......@@ -701,6 +701,14 @@ stages:
# --- l --- [[[2
'ldap role':
<<: *test_role_1st_deps
variables:
JANE_TEST_PLAY: '${DEBOPS_PLAYBOOKS}/service/ldap.yml'
JANE_INVENTORY_GROUPS: 'debops_service_ldap'
JANE_DIFF_PATTERN: '.*/debops.ldap/.*'
JANE_LOG_PATTERN: '\[debops\.ldap\]'
'librenms role':
<<: *test_role_3rd_deps
variables:
......
......@@ -89,6 +89,15 @@
- role: debops.python
tags: [ 'role::python', 'skip::python' ]
python__dependent_packages3:
- '{{ ldap__python__dependent_packages3 }}'
python__dependent_packages2:
- '{{ ldap__python__dependent_packages2 }}'
# LDAP client initialization should be done separately to prepare local
# facts for other roles to use in configuration.
- role: debops.ldap
tags: [ 'role::ldap', 'skip::ldap' ]
- role: debops.apt_listchanges
tags: [ 'role::apt_listchanges', 'skip::apt_listchanges' ]
......
---
- name: Initialize new LDAP directory
hosts: [ 'debops_service_slapd' ]
become: True
environment: '{{ inventory__environment | d({})
| combine(inventory__group_environment | d({}))
| combine(inventory__host_environment | d({})) }}'
vars_prompt:
- name: 'admin_plaintext_password'
prompt: 'New password for your LDAP user account'
private: True
vars:
# Username of the current Ansible user on the Ansible Controller
admin_user: '{{ lookup("env", "USER") }}'
# Information from the 'passwd' database for the current user on the
# Ansible Controller
admin_gecos: '{{ getent_passwd[admin_user][3] }}'
# SSH public keys in the 'ssh-agent'
admin_sshkeys: "{{ lookup('pipe', 'ssh-add -L').split('\n') }}"
# Password of the administrator account, stored in the Password Store on
# the Ansible Controller
admin_saved_password: '{{ lookup("passwordstore", ldap__admin_passwordstore_path
+ "/" + (admin_dn | to_uuid)
+ " create=true overwrite=true userpass="
+ admin_plaintext_password) }}'
# The Relative Distinguished Name of the administrator account in the LDAP
# directory
admin_rdn: 'uid={{ admin_user }}'
# The Distinguished Name of the administrator account
admin_dn: '{{ ([ admin_rdn, ldap__people_rdn ] + ldap__base_dn) | join(",") }}'
# Override the check if the LDAP support is enabled on the host, we don't
# care at this point
ldap__enabled: True
# Override the check if the LDAP support is configured on the host, we
# don't care at this point
ldap__configured: True
# Run the 'debops.ldap' role in dependent mode; don't configure anything
# related to LDAP on the host iself, perform only LDAP tasks
ldap__dependent_play: True
# Override the list of LDAP servers detected automatically by the role
ldap__servers: [ '{{ ansible_fqdn }}' ]
# Use the RootDN credential to access the LDAP directory directly via the
# superuser account
ldap__admin_binddn: '{{ ([ "cn=admin" ] + ldap__base_dn) | join(",") }}'
# Use the RootPW credential generated by the 'debops.slapd' role to
# authenticate to the LDAP directory
ldap__admin_bindpw: '{{ lookup("password", secret + "/slapd/credentials/"
+ ldap__admin_binddn | to_uuid
+ ".password").split()[0] }}'
ldap__dependent_tasks:
- name: 'Remove the default cn=admin object'
dn: '{{ [ "cn=admin" ] + ldap__base_dn }}'
state: 'absent'
entry_state: 'absent'
- name: 'Create the {{ ldap__system_groups_rdn }} object'
dn: '{{ [ ldap__system_groups_rdn ] + ldap__base_dn }}'
objectClass: 'organizationalUnit'
- name: 'Create the {{ ldap__groups_rdn }} object'
dn: '{{ [ ldap__groups_rdn ] + ldap__base_dn }}'
objectClass: 'organizationalUnit'
- name: 'Create the {{ ldap__machines_rdn }} object'
dn: '{{ [ ldap__machines_rdn ] + ldap__base_dn }}'
objectClass: 'organizationalUnit'
- name: 'Create the {{ ldap__hosts_rdn }} object'
dn: '{{ [ ldap__hosts_rdn ] + ldap__base_dn }}'
objectClass: 'organizationalUnit'
- name: 'Create the {{ ldap__people_rdn }} object'
dn: '{{ [ ldap__people_rdn ] + ldap__base_dn }}'
objectClass: 'organizationalUnit'
- name: 'Create the {{ ldap__services_rdn }} object'
dn: '{{ [ ldap__services_rdn ] + ldap__base_dn }}'
objectClass: 'organizationalUnit'
- name: 'Create the ou=Password Policies object'
dn: '{{ [ "ou=Password Policies" ] + ldap__base_dn }}'
objectClass: 'organizationalUnit'
# Refer to slapo-ppolicy(5) for details
- name: 'Create the cn=Default Password Policy object'
dn: '{{ [ "cn=Default Password Policy", "ou=Password Policies" ] + ldap__base_dn }}'
objectClass: [ 'namedObject', 'pwdPolicy' ]
attributes:
cn: 'Default Password Policy'
pwdAttribute: 'userPassword'
pwdMaxAge: '0'
pwdInHistory: '5'
pwdCheckQuality: '1'
pwdMinLength: '10'
pwdExpireWarning: '3600'
pwdGraceAuthNLimit: '5'
pwdLockout: 'FALSE'
pwdLockoutDuration: '300'
pwdMaxFailure: '5'
pwdFailureCountInterval: '0'
pwdMustChange: 'FALSE'
pwdAllowUserChange: 'TRUE'
pwdSafeModify: 'FALSE'
- name: 'Create personal account for {{ admin_gecos.split(",")[0] }}'
dn: '{{ [ admin_rdn, ldap__people_rdn ] + ldap__base_dn }}'
objectClass: [ 'inetOrgPerson', 'posixAccount', 'shadowAccount',
'posixGroup', 'posixGroupId', 'ldapPublicKey',
'authorizedServiceObject', 'hostObject' ]
attributes:
# inetOrgPerson attributes
commonName: '{{ admin_gecos.split(",")[0] }}'
givenName: '{{ admin_gecos.split(",")[0].split()[0] if (" " in admin_gecos) else "" }}'
surname: '{{ admin_gecos.split(",")[0].split()[1] if (" " in admin_gecos) else admin_gecos }}'
userPassword: '{{ admin_plaintext_password }}'
# POSIX attributes
uid: '{{ admin_rdn.split("=")[1] }}'
gid: '{{ admin_rdn.split("=")[1] }}'
uidNumber: '{{ ldap__groupid_max|int + 1 }}'
gidNumber: '{{ ldap__groupid_max|int + 1 }}'
homeDirectory: '{{ ldap__home + "/" + admin_user }}'
loginShell: '{{ ldap__shell }}'
# Other attributes
authorizedService: '*'
host: '*'
sshPublicKey: '{{ admin_sshkeys }}'
- name: 'Create cn=LDAP Administrators group'
dn: '{{ [ "cn=LDAP Administrators", ldap__system_groups_rdn ] + ldap__base_dn }}'
objectClass: 'groupOfNames'
attributes:
cn: 'LDAP Administrators'
member: '{{ admin_dn }}'
owner: '{{ admin_dn }}'
description: 'People responsible for LDAP infrastructure'
- name: 'Create cn=LDAP Editors group'
dn: '{{ [ "cn=LDAP Editors", ldap__system_groups_rdn ] + ldap__base_dn }}'
objectClass: 'groupOfNames'
attributes:
cn: 'LDAP Editors'
member: '{{ admin_dn }}'
owner: '{{ admin_dn }}'
description: 'People responsible for LDAP contents'
- name: 'Create cn=UNIX Administrators group'
dn: '{{ [ "cn=UNIX Administrators", ldap__system_groups_rdn ] + ldap__base_dn }}'
objectClass: [ 'groupOfNames', 'posixGroup', 'posixGroupId' ]
attributes:
cn: 'UNIX Administrators'
gid: 'admins'
gidNumber: '{{ ldap__groupid_min }}'
member: '{{ admin_dn }}'
owner: '{{ admin_dn }}'
description: 'People responsible for UNIX-like infrastructure'
- name: 'Create cn=Account Administrators group'
dn: '{{ [ "CN=Account Administrators", ldap__system_groups_rdn ] + ldap__base_dn }}'
objectClass: 'groupOfNames'
attributes:
cn: 'Account Administrators'
member: '{{ admin_dn }}'
owner: '{{ admin_dn }}'
description: 'People responsible for personal accounts'
pre_tasks:
- name: Check local user information
getent:
database: 'passwd'
key: '{{ admin_user }}'
delegate_to: 'localhost'
become: False
- name: Save admin credential in the password store
set_fact:
admin_stored_password: '{{ admin_saved_password }}'
no_log: True
delegate_to: 'localhost'
become: False
run_once: True
roles:
- role: 'debops.ldap'
tags: [ 'role::ldap', 'skip::ldap' ]
---
# This playbook can be used to save the LDAP password in the password store
# (encrypted with user's GPG key). The password can then be used later by the
# 'debops.ldap' role to perform LDAP tasks on behalf of the user.
#
# Check the documentation of the 'debops.ldap' Ansible role for more details.
- name: Save personal credential in the password store
hosts: [ 'debops_service_slapd' ]
environment: '{{ inventory__environment | d({})
| combine(inventory__group_environment | d({}))
| combine(inventory__host_environment | d({})) }}'
vars:
# Don't make any changes related to LDAP on the host against which this
# playbook is executed. The playbook relies on the role default variables
# (or their inventory overrides) to find the full DN of the user account.
ldap__enabled: False
# The username of the credential owner
person_rdn: 'uid={{ person_uid.user_input }}'
# The LDAP Distinguished Name of the credential owner
person_dn: '{{ object_dn.user_input
if object_dn.user_input|d()
else ((([ person_rdn, ldap__people_rdn ] + ldap__base_dn) | join(","))
if person_uid.user_input|d()
else "") }}'
# This variable defines the lookup plugin command that will be executed by
# the 'set_fact' task later on to trigger the 'passwordstore' lookup plugin
# to save the new password given by the user.
person_store_password: '{{ lookup("passwordstore", ldap__passwordstore_path
+ "/" + (person_dn | to_uuid)
+ " create=true overwrite=true userpass="
+ person_password) }}'
pre_tasks:
- name: 'Specify username'
pause:
prompt: 'LDAP username (uid=%s,{{ ([ ldap__people_rdn ] + ldap__base_dn) | join(",") }})'
register: person_uid
delegate_to: 'localhost'
become: False
run_once: True
- name: 'Username not provided, specify DN'
pause:
prompt: 'LDAP Distinguished Name'
register: object_dn
when: person_uid is undefined or not person_uid.user_input|d()
delegate_to: 'localhost'
become: False
run_once: True
- name: Make sure that we have a Distinguished Name
assert:
that:
- person_dn != ""
fail_msg: 'No Distinguished Name provided, aborting'
success_msg: 'dn: {{ person_dn }} | UUID: {{ person_dn | to_uuid }}'
delegate_to: 'localhost'
become: False
run_once: True
- name: 'Specify password'
pause:
prompt: 'LDAP password [random]'
echo: False
register: person_plaintext_password
delegate_to: 'localhost'
become: False
run_once: True
- name: Generate random password if not specified
set_fact:
person_password: '{{ person_plaintext_password.user_input
if person_plaintext_password.user_input|d()
else lookup("password", "/dev/null") }}'
delegate_to: 'localhost'
become: False
run_once: True
- name: Save credential in the password store
set_fact:
person_saved_password: '{{ person_store_password }}'
no_log: True
delegate_to: 'localhost'
become: False
run_once: True
post_tasks:
- name: Display randomly generated password
debug:
msg: '{{ {"Distinguished Name": person_dn,
"UUID": (person_dn | to_uuid),
"Stored password": person_password} }}'
when: not person_plaintext_password.user_input|d()
delegate_to: 'localhost'
become: False
run_once: True
roles:
- role: 'debops.ldap'
tags: [ 'role::ldap', 'skip::ldap' ]
---
- name: Manage LDAP basic configuration
hosts: [ 'debops_all_hosts', 'debops_service_ldap' ]
become: True
environment: '{{ inventory__environment | d({})
| combine(inventory__group_environment | d({}))
| combine(inventory__host_environment | d({})) }}'
roles:
- role: debops.python
tags: [ 'role::python', 'skip::python', 'role::ldap' ]
python__dependent_packages3:
- '{{ ldap__python__dependent_packages3 }}'
python__dependent_packages2:
- '{{ ldap__python__dependent_packages2 }}'
- role: debops.ldap
tags: [ 'role::ldap', 'skip::ldap' ]
debops.ldap - Manage system-wide LDAP configuration and directory objects
Copyright (C) 2019 Maciej Delmanowski <drybjed@gmail.com>
Copyright (C) 2019 DebOps Project https://debops.org/
This Ansible role is part of DebOps.
DebOps is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License version 3, as
published by the Free Software Foundation.
DebOps is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with DebOps. If not, see http://www.gnu.org/licenses/.
This diff is collapsed.
---
dependencies:
- role: debops.secret
- role: debops.ansible_plugins
galaxy_info:
role_name: 'ldap'
company: 'DebOps'
author: 'Maciej Delmanowski'
description: 'Manage system-wide LDAP configuration and directory objects'
license: 'GPL-3.0'
min_ansible_version: '2.7.0'
platforms:
- name: Ubuntu
versions:
- xenial
- bionic
- name: Debian
versions:
- jessie
- stretch
- buster
galaxy_tags:
- ldap
- openldap
---
- name: '{{ item.name }}'
ldap_entry:
dn: '{{ item.dn if (item.dn is string) else item.dn | join(",") }}'
objectClass: '{{ item.objectClass | d(omit) }}'
attributes: '{{ item.attributes | d(omit) }}'
state: '{{ item.entry_state | d(item.state) }}'
params: '{{ ldap__admin_auth_params }}'
become: '{{ ldap__admin_become }}'
become_user: '{{ ldap__admin_become_user if ldap__admin_become_user|d() else omit }}'
delegate_to: '{{ ldap__admin_delegate_to if ldap__admin_delegate_to|d() else omit }}'
run_once: '{{ item.run_once | d(False) }}'
when: (item.objectClass|d() or item.entry_state|d()) and item.state not in [ 'init', 'ignore' ]
no_log: '{{ item.no_log | d(True
if ("userPassword" in (item.attributes|d({})).keys() or
"olcRootPW" in (item.attributes|d({})).keys())
else False) }}'
- name: '{{ item.name }}'
ldap_attrs:
dn: '{{ item.dn if (item.dn is string) else item.dn | join(",") }}'
attributes: '{{ item.attributes | d({}) }}'
ordered: '{{ item.ordered | d(False) }}'
state: '{{ item.state }}'
params: '{{ ldap__admin_auth_params }}'
become: '{{ ldap__admin_become }}'
become_user: '{{ ldap__admin_become_user if ldap__admin_become_user|d() else omit }}'
delegate_to: '{{ ldap__admin_delegate_to if ldap__admin_delegate_to|d() else omit }}'
run_once: '{{ item.run_once | d(False) }}'
when: not item.objectClass|d() and not item.entry_state|d() and item.state not in [ 'init', 'ignore' ]
no_log: '{{ item.no_log | d(True
if ("userPassword" in (item.attributes|d({})).keys() or
"olcRootPW" in (item.attributes|d({})).keys())
else False) }}'
---
- name: Take note of the current LDAP configuration
set_fact:
# Track the changes in the configuration state
# between role executions in the same play.
ldap__fact_configured: '{{ ldap__configured }}'
# Re-instantiate dependent variables to evaluate variables that use them.
# Without this, dependent variables may contain outdated configuration.
ldap__fact_dependent_tasks: '{{ ldap__dependent_tasks }}'
- name: Install packages required for LDAP support
package:
name: '{{ q("flattened", ldap__base_packages + ldap__packages) }}'
state: 'present'
register: ldap__register_packages
until: ldap__register_packages is succeeded
when: ldap__enabled|bool and not ldap__dependent_play|bool
- name: Divert original LDAP client configuration
command: dpkg-divert --quiet --local
--divert /etc/ldap/ldap.conf.dpkg-divert
--rename /etc/ldap/ldap.conf
args:
creates: '/etc/ldap/ldap.conf.dpkg-divert'
when: ldap__enabled|bool and not ldap__dependent_play|bool
- name: Generate system-wide LDAP configuration
template:
src: 'etc/ldap/ldap.conf.j2'
dest: '/etc/ldap/ldap.conf'
mode: '0644'
when: ldap__enabled|bool and not ldap__dependent_play|bool
- name: Make sure that Ansible local facts directory exists
file:
path: '/etc/ansible/facts.d'
state: 'directory'
mode: '0755'
when: ldap__enabled|bool and not ldap__dependent_play|bool
- name: Save LDAP client local facts
template:
src: 'etc/ansible/facts.d/ldap.fact.j2'
dest: '/etc/ansible/facts.d/ldap.fact'
mode: '0755'
register: ldap__register_facts
when: ldap__enabled|bool and not ldap__dependent_play|bool
- name: Update Ansible facts if they were modified
action: setup
when: ldap__enabled|bool and not ldap__dependent_play|bool and
ldap__register_facts is changed
- name: Check if LDAP admin password is available
set_fact:
ldap__fact_admin_bindpw: '{{ ldap__admin_bindpw }}'
become: '{{ ldap__admin_become }}'
become_user: '{{ ldap__admin_become_user }}'
delegate_to: '{{ ldap__admin_delegate_to }}'
run_once: True
- name: Perform LDAP tasks
include_tasks: 'ldap_tasks.yml'
loop: '{{ q("flattened", ldap__combined_tasks) | parse_kv_items }}'
loop_control:
label: '{{ {"state": item.state,
"dn": item.dn,
"attributes": item.attributes|d({})} }}'
when: ldap__enabled|bool and ldap__admin_enabled|bool and
item.name|d() and item.dn|d() and
item.state|d('present') not in [ 'init', 'ignore' ]
no_log: '{{ item.no_log | d(True
if ("userPassword" in (item.attributes|d({})).keys() or
"olcRootPW" in (item.attributes|d({})).keys())
else False) }}'
#!{{ ansible_python['executable'] }}
# {{ ansible_managed }}
from __future__ import print_function
from json import dumps, loads
import os
try:
import ldap
except ImportError:
pass
config_file = '/etc/ldap/ldap.conf'
output = loads('''{{ {"configured": False,
"enabled": ldap__enabled|bool,
"system_groups_rdn": ldap__system_groups_rdn,
"groups_rdn": ldap__groups_rdn,
"hosts_rdn": ldap__hosts_rdn,
"machines_rdn": ldap__machines_rdn,
"people_rdn": ldap__people_rdn,
"services_rdn": ldap__services_rdn,
"device_dn": (ldap__device_dn
if ldap__device_enabled|bool
else []),
"uid_gid_min": ldap__uid_gid_min,
"groupid_min": ldap__groupid_min,
"groupid_max": ldap__groupid_max,
"uid_gid_max": ldap__uid_gid_max,
"home": ldap__home,
"shell": ldap__shell
} | to_nice_json }}''')
if os.path.exists(config_file) and os.path.isfile(config_file):
try:
conf = []
with open(config_file, 'r') as f:
for line in f:
if not line.startswith('#') and line.strip():
line = line.strip().split()
key = line[0].lower()
value = line[1]
raw_value = line[1]
if key in ['uri', 'host']:
value = line[1:]
raw_value = ' '.join(line[1:])
elif key in ['base']:
value = ' '.join(line[1:])
raw_value = ' '.join(line[1:])
elif key in ['referrals', 'gssapi_sign', 'gssapi_encrypt',
'gssapi_allow_remote_principal']:
if line[1].lower() in ['on', 'true', 'yes']:
value = True
elif line[1].lower() in ['off', 'false', 'no']:
value = False
conf.append({key: raw_value})
if key == 'base':
output['base_dn'] = (
ldap.dn.explode_dn(value.encode()))
output['basedn'] = (
ldap.dn.dn2str(
ldap.dn.str2dn(value.encode())))
elif key == 'uri':
hosts = []
for url in value:
parts = url.split('://', 1)
output['protocol'] = parts[0]
if output['protocol'] == 'ldap':
output['port'] = '389'
elif output['protocol'] == 'ldaps':
output['port'] = '636'
hosts.append(parts[1].split('/', 1)[0])
output['uri'] = value
output['hosts'] = hosts
else:
output.update({key: value})
if output['protocol'] == 'ldap':
output['start_tls'] = True
elif output['protocol'] == 'ldaps':
output['start_tls'] = False
if output['enabled']:
output['configured'] = True
output['conf'] = conf
except Exception:
pass
print(dumps(output, sort_keys=True, indent=4))
# {{ ansible_managed }}
#
# LDAP Defaults
#
# See ldap.conf(5) for details
# This file should be world readable but not world writable.
{% for option in ldap__combined_configuration | parse_kv_config %}
{% if option.state|d('present') not in [ 'absent', 'ignore' ] %}
{% if option.separator|bool and not option.comment|d() and not loop.first %}
{% endif %}
{% if option.comment|d() %}
{{ option.comment | regex_replace('\n$','') | comment(prefix='', postfix='') -}}
{% endif %}
{{ '{}{}\t{}{}'.format(('#' if (option.state|d('present') == 'comment') else ''), option.name | upper, ('\t' if (option.name | count < 8) else ''), (option.value if option.value is string else (option.value | selectattr("name", "defined") | map(attribute="name") | list | join(' ')))) }}
{% endif %}
{% endfor %}
{% set ldap__tpl_device_interfaces = [] %}
{% set ldap__tpl_device_masters = [] %}
{% set ldap__tpl_device_ip_addresses = [] %}
{% for item in ansible_interfaces %}
{% set iface = (item | replace('-','_') | regex_replace('^(.*)$', 'ansible_\\1')) %}
{% if iface.startswith(('ansible_en', 'ansible_eth', 'ansible_vlan')) %}
{% set _ = ldap__tpl_device_interfaces.append(item) %}
{% elif hostvars[inventory_hostname][iface].type|d('ether') == 'bridge' %}
{% set _ = ldap__tpl_device_masters.append(item) %}
{% endif %}
{% endfor %}
{% for item in ldap__tpl_device_interfaces %}
{% set iface = (item | replace('-','_') | regex_replace('^(.*)$', 'ansible_\\1')) %}
{% if hostvars[inventory_hostname][iface].ipv6|d() %}
{% for element in hostvars[inventory_hostname][iface].ipv6 %}
{% set address = ((element.address + '/' + element.prefix) | ipaddr('host/prefix')) %}
{% if not address | ipv6('link-local') %}
{% set _ = ldap__tpl_device_ip_addresses.append(element.address) %}
{% endif %}
{% endfor %}
{% endif %}
{% if hostvars[inventory_hostname][iface].ipv4|d() %}
{% if hostvars[inventory_hostname][iface].ipv4.address|d() %}
{% set _ = ldap__tpl_device_ip_addresses.append(hostvars[inventory_hostname][iface].ipv4.address) %}
{% endif %}
{% if hostvars[inventory_hostname][iface].ipv4_secondaries|d() %}
{% for element in hostvars[inventory_hostname][iface].ipv4_secondaries %}
{% set _ = ldap__tpl_device_ip_addresses.append(element.address) %}
{% endfor %}
{% endif %}
{% endif %}