diff --git a/CODEOWNERS b/CODEOWNERS index f7fcec8..4e90333 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1,2 @@ * @jcgruenhage @Ratzupaltuff +/roles/ldap/ @jcgruenhage @transcaffeine diff --git a/roles/ldap/README.md b/roles/ldap/README.md new file mode 100644 index 0000000..9c09b26 --- /dev/null +++ b/roles/ldap/README.md @@ -0,0 +1,52 @@ +# openLDAP role + +## Description + +Deploys [`famedly/containers/openldap`](https://gitlab.com/famedly/containers/openldap), +which is openldap running in an alpine linux-based docker container. +The `core.schema`, `cosine.schema` and `inetOrgPerson.schema` are loaded by default, +and an MDB database is configured for the `ldap_domain`. + +Access control lists (ACLs) can be specified in `ldap_acls` and are applied to the MDB database. +A root user can be specified and has full access on the database, +full access to the config (`cn=config`) is given to local root +(`gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth`). + +An `organizationalUnit` for users and groups is created per default, +the users should be created as `uid=$userName,ou=users,$ldap_dn` with `objectClass=inetOrgPerson`, +and groups would be `cn=$groupName,ou=groups,$ldap_dn` with `objectClass=groupOfNames`. + +## Requirements + +Needs `python-ldap` installed for the `ldap_entry`/`ldap_attr` modules to be able to connect. +Docker daemon also needs to run and be accessable from the `ansible_user`. +The role uses privilege escalation to become host-root to be able +to set the ACLs in the container (which needs root there). + +## Usage + +- `ldap_domain`: Where the LDAP server runs, e.g. 'example.org'. + The root node and it's DN are constructed from this value. + +- `ldap_root_user`/`ldap_root_pass`/`ldap_root_pass_hash`: The root user + of the database and the password in cleartext and the hashed form of the password + which gets written into the config. The rootDN is constructed from + `ldap_root_user`+`ldap_dn` (`ldap_dn` is constructed from `ldap_domain`). + +- `ldap_org`: Name of the organization. A root node in the DIT is automatically + created and the Organization name can be set here. + +- `ldap_org_units`: Additional `organizationalUnit`s the role creates at the top + level of the DIT. Defaults to `[ groups, users ]`. + +- `ldap_additional_schemas`: Can be populated with dicts of the form + `{name: "example.ldif", content: "schema_definition_here"}` to load those schemas + into the ldap config tree during initial setup. + +- `ldap_additional_indices`: Can be used to declare additional indices on the `mdb` + database, like `uid eq` (maintains an equality index on the `uid` attribute). + +See the [test playbook](tests/test.yml) for an example of how to use the role. + +You can use `sudo ANSIBLE_ROLES_PATH="$(pwd)/roles" ansible-playbook -i roles/ldap/tests/inventory roles/ldap/tests/test.yml` +from the collection-directory to run the tests. diff --git a/roles/ldap/defaults/main.yml b/roles/ldap/defaults/main.yml new file mode 100644 index 0000000..4b308c5 --- /dev/null +++ b/roles/ldap/defaults/main.yml @@ -0,0 +1,58 @@ +--- +ldap_base_path: /opt/ldap +ldap_sock_path: "{{ ldap_base_path }}/sock" +ldap_data_path: "{{ ldap_base_path }}/data" +ldap_config_path: "{{ ldap_base_path }}/config" + +ldap_container_version: "v2.4.50-r0" +ldap_container_image: "registry.gitlab.com/famedly/containers/openldap:{{ ldap_container_version }}" +ldap_container_name: ldap +ldap_container_ports: "389:389" +ldap_server_uri: "ldap:///" +ldap_container_labels: {} +ldap_container_networks: [] +ldap_container_etc_hosts: {} +ldap_container_pull: true +ldap_container_recreate: false + +ldap_container_fd_soft_limit: "8192" +ldap_container_fd_hard_limit: "8192" +ldap_container_ulimits: ["nofile:{{ ldap_container_fd_soft_limit }}:{{ ldap_container_fd_hard_limit }}"] +ldap_container_memory_reservation: "256M" +ldap_container_memory: "512M" +ldap_init_container_additional_volumes: + - "{{ ldap_base_path }}/slapd.ldif:{{ ldap_container_slapd_file }}:z" + +# phpLDAPadmin config +ldap_management_container_enabled: False +ldap_management_container_name: "ldap_management" +ldap_management_version: "0.9.0" +ldap_management_container_image: "docker.io/osixia/phpldapadmin:{{ ldap_management_version }}" +ldap_management_container_ports: [] +ldap_management_container_env: {} +ldap_management_container_labels: {} +ldap_management_container_pull: true +ldap_management_container_recreate: false + +# LDAP specific config +ldap_domain: ~ +ldap_dn: "dc={{ ldap_domain | regex_replace('\\.', ',dc=') }}" +ldap_org: ~ +ldap_root_user: "admin" +ldap_root_pass: ~ +ldap_root_pass_hash: ~ +ldap_rootdn: "cn={{ ldap_root_user }},{{ ldap_dn }}" +ldap_config_db: "olcDatabase={1}mdb,cn=config" + +# Expects {name: "costumSchema.ldif", content: $fileContent} +ldap_additional_schemas: [] +ldap_additional_indices: [] +ldap_org_units: + - groups + - users + +# Default ACLs +ldap_acls: + - "{0} to dn.subtree=\"{{ ldap_dn }}\" by dn.exact=\"{{ ldap_rootdn }}\" manage by * break" + - "{1} to attrs=userPassword by anonymous auth by self =w by * none" + - "{2} to * by users read" diff --git a/roles/ldap/docs/MAINTENANCE.md b/roles/ldap/docs/MAINTENANCE.md new file mode 100644 index 0000000..95f11fb --- /dev/null +++ b/roles/ldap/docs/MAINTENANCE.md @@ -0,0 +1,13 @@ +# Maintenance + +## Updating + +When the ldap container image is updated, one needs to make +sure the config template is still up-to-date. + +To do this, run `docker run --rm registry.gitlab.com/famedly/containers/openldap:$VERSION cat /etc/openldap/slapd.ldif > templates/slapd_$VERSION.ldif` +and use `diff templates/slapd_$VERSION templates/slapd.ldif.j2`. + +When you integrated potential config changes, make sure that the +header in `templates/slapd.ldif.j2` is up-to-date AND both the +version bump and the config change are done in a SINGLE commit. diff --git a/roles/ldap/tasks/configure.yml b/roles/ldap/tasks/configure.yml new file mode 100644 index 0000000..27d583d --- /dev/null +++ b/roles/ldap/tasks/configure.yml @@ -0,0 +1,87 @@ +--- + +# Configures the ACL via root on LDAP-IPC-Socket +- name: Configure ACL + become: true + ldap_attr: + dn: "{{ ldap_config_db }}" + name: olcAccess + values: "{{ ldap_acls }}" + state: exact + server_uri: "ldapi://{{ (ldap_sock_path + '/slapd.sock') | urlencode | replace('/', '%2F') }}" + retries: 3 + delay: 3 + register: acl_res + until: acl_res is succeeded + tags: + - ldap-sync + - ldap-sync-acl + +- name: Ensure rootDN credentials up-to-date + become: true + ldap_attr: + dn: "{{ ldap_config_db }}" + name: "{{ item.key }}" + values: "{{ item.value }}" + state: exact + server_uri: "ldapi://{{ (ldap_sock_path + '/slapd.sock') | urlencode | replace('/', '%2F') }}" + no_log: "{{ item.log is defined and item.log == false }}" + loop: + - key: olcRootDN + value: "{{ ldap_rootdn }}" + - key: olcRootPW + value: "{{ ldap_root_pass }}" + log: false + tags: + - ldap-sync + +# Root node can be created with normal bind via LDAP +- name: Create root node + become: true + ldap_entry: + dn: "{{ ldap_dn }}" + objectClass: + - top + - dcObject + - organization + attributes: + dc: "{{ ldap_domain|regex_replace('\\..+', '') }}" + o: "{{ ldap_org }}" + bind_dn: "{{ ldap_rootdn }}" + bind_pw: "{{ ldap_root_pass }}" + server_uri: "{{ ldap_server_uri }}" + tags: + - ldap-sync + +- name: Ensure root node is correctly configured + become: true + ldap_attr: + dn: "{{ ldap_dn }}" + name: "{{ item.key }}" + values: "{{ item.value }}" + state: exact + bind_dn: "{{ ldap_rootdn }}" + bind_pw: "{{ ldap_root_pass }}" + server_uri: "{{ ldap_server_uri }}" + no_log: "{{ item.log is defined and item.log == false }}" + loop: + - key: o + value: "{{ ldap_org }}" + - key: dc + value: "{{ ldap_domain|regex_replace('\\..+', '') }}" + tags: + - ldap-sync + +- name: Create organizational units + ldap_entry: + dn: "ou={{ ou_name }},{{ ldap_dn }}" + objectClass: organizationalUnit + state: present + bind_dn: "{{ ldap_rootdn }}" + bind_pw: "{{ ldap_root_pass }}" + server_uri: "{{ ldap_server_uri }}" + loop: "{{ ldap_org_units }}" + loop_control: + loop_var: ou_name + tags: + - ldap-sync diff --git a/roles/ldap/tasks/initialize.yml b/roles/ldap/tasks/initialize.yml new file mode 100644 index 0000000..1601640 --- /dev/null +++ b/roles/ldap/tasks/initialize.yml @@ -0,0 +1,64 @@ +--- + +- name: Stat the LDAP OLC config directory + stat: + path: "{{ ldap_config_path }}/cn=config" + register: stat_result + +- name: Determine if the container needs to be initialized + set_fact: + ldap_needs_init: "{{ stat_result.stat.exists|bool == False }}" + +- name: (init) Template initial slapd.ldif + template: + src: slapd.ldif.j2 + dest: "{{ ldap_base_path }}/slapd.ldif" + mode: 0644 + when: ldap_needs_init|bool + +- name: (init) Copy additional schema + copy: + content: "{{ schema.content }}" + dest: "{{ ldap_base_path }}/{{ schema.name }}" + mode: 0644 + when: ldap_needs_init|bool + loop: "{{ ldap_additional_schemas }}" + loop_control: + loop_var: schema + label: "{{ schema.name }}" + +- name: (init) Map additional schemas into container + set_fact: + ldap_init_container_additional_volumes: >- + {{ ldap_init_container_additional_volumes }} + + {{ volume_mount }} + vars: + schema_file: "{{ ldap_base_path }}/{{ schema.name }}" + volume_mount: + - "{{ schema_file }}:{{ ldap_container_schema_path }}/{{ schema.name }}:ro" + when: ldap_needs_init|bool + loop: "{{ ldap_additional_schemas }}" + loop_control: + loop_var: schema + label: "{{ schema.name }}" + +# The detach and cleanup options force the task to +# stall until slapadd is done. Else, an unconfigured +# container is started which will not work +- name: (init) Run init script in container + docker_container: + name: "{{ ldap_container_name }}" + image: "{{ ldap_container_image }}" + command: "slapadd -v -F {{ ldap_container_conf_dir }} -n 0 -l {{ ldap_container_slapd_file }}" + cleanup: yes + detach: no + container_default_behavior: no_defaults + pull: "{{ ldap_container_pull }}" + volumes: "{{ ldap_container_volumes + ldap_init_container_additional_volumes }}" + when: ldap_needs_init|bool + +- name: (init) Remove files needed for bootstrapping + file: + path: "{{ ldap_base_path }}/{{ item }}" + state: absent + loop: "{{ [ { name: 'slapd.ldif'} ] + ldap_additional_schemas }}" diff --git a/roles/ldap/tasks/ldap-web-ui.yml b/roles/ldap/tasks/ldap-web-ui.yml new file mode 100644 index 0000000..fb00abb --- /dev/null +++ b/roles/ldap/tasks/ldap-web-ui.yml @@ -0,0 +1,26 @@ +--- + +- name: Ensure LDAP management container is started + docker_container: + name: "{{ ldap_management_container_name }}" + image: "{{ ldap_management_container_image }}" + ports: "{{ ldap_management_container_ports }}" + labels: "{{ ldap_management_container_labels_complete }}" + env: "{{ ldap_management_container_env }}" + links: + - "{{ ldap_container_name }}:ldap" + restart_policy: unless-stopped + recreate: "{{ ldap_management_container_recreate }}" + pull: "{{ ldap_management_container_pull }}" + healthcheck: + test: >- + [ $(pgrep -u www-data -c -f /usr/sbin/apache2) -gt 0 ] + || exit 1 + + when: ldap_management_container_enabled|bool + +- name: Ensure LDAP management container is absent + docker_container: + name: "{{ ldap_management_container_name }}" + state: absent + when: not ldap_management_container_enabled|bool diff --git a/roles/ldap/tasks/main.yml b/roles/ldap/tasks/main.yml new file mode 100644 index 0000000..84d7aa6 --- /dev/null +++ b/roles/ldap/tasks/main.yml @@ -0,0 +1,47 @@ +--- + +- name: Ensure base path exists + file: + path: "{{ ldap_base_path }}" + state: directory + mode: 0700 + +- name: Create volume paths + file: + path: "{{ item }}" + state: directory + mode: 0700 + loop: + - "{{ ldap_data_path }}" + - "{{ ldap_config_path }}" + - "{{ ldap_base_path }}/sock" + +- name: Provide intial container configuration + include_tasks: initialize.yml + +- name: Ensure LDAP container is started + docker_container: + name: "{{ ldap_container_name }}" + image: "{{ ldap_container_image }}" + ports: "{{ ldap_container_ports }}" + volumes: "{{ ldap_container_volumes }}" + labels: "{{ ldap_container_labels_complete }}" + networks: "{{ ldap_container_networks }}" + etc_hosts: "{{ ldap_container_etc_hosts }}" + ulimits: "{{ ldap_container_ulimits }}" + memory_reservation: "{{ ldap_container_memory_reservation }}" + memory: "{{ ldap_container_memory }}" + restart_policy: unless-stopped + recreate: "{{ ldap_container_recreate }}" + pull: "{{ ldap_container_pull }}" + healthcheck: + test: >- + [[ $(netstat -plnte | grep slapd | wc -l) -ge 1 ]] + && [[ $(ps aux | grep slapd | wc -l) -ge 1 ]] + || exit 1 + +- name: Configure LDAP DIT + import_tasks: configure.yml + +- name: Set up phpLDAPAdmin container + import_tasks: ldap-web-ui.yml diff --git a/roles/ldap/templates/slapd.ldif.j2 b/roles/ldap/templates/slapd.ldif.j2 new file mode 100644 index 0000000..f3224e6 --- /dev/null +++ b/roles/ldap/templates/slapd.ldif.j2 @@ -0,0 +1,115 @@ +# This config template is based on the slapd.ldif which is shipped in +# https://gitlab.com/famedly/containers/openldap:v2.4.50-r1 +# For updating, see docs/MAINTENANCE.md +# +# See slapd-config(5) for details on configuration options. +# This file should NOT be world readable. +# +dn: cn=config +objectClass: olcGlobal +cn: config +# +# +# Define global ACLs to disable default read access. +# +# If you change this, set pidfile variable in /etc/conf.d/slapd! +olcPidFile: /run/openldap/slapd.pid +olcArgsFile: /run/openldap/slapd.args +# +# Do not enable referrals until AFTER you have a working directory +# service AND an understanding of referrals. +#olcReferral: ldap://root.openldap.org +# +# Sample security restrictions +# Require integrity protection (prevent hijacking) +# Require 112-bit (3DES or better) encryption for updates +# Require 64-bit encryption for simple bind +#olcSecurity: ssf=1 update_ssf=112 simple_bind=64 + + +# +# Load dynamic backend modules: +# +dn: cn=module,cn=config +objectClass: olcModuleList +cn: module +olcModulepath: /usr/lib/openldap +#olcModuleload: back_bdb.so +#olcModuleload: back_hdb.so +#olcModuleload: back_ldap.so +olcModuleload: back_mdb.so +#olcModuleload: back_passwd.so +#olcModuleload: back_shell.so + + +dn: cn=schema,cn=config +objectClass: olcSchemaConfig +cn: schema + +include: file:///etc/openldap/schema/core.ldif +{% for schema in ldap_schemas_to_load %} +include: file://{{ ldap_container_schema_path }}/{{ schema.name }} +{% endfor %} + + +# Frontend settings +# +dn: olcDatabase=frontend,cn=config +objectClass: olcDatabaseConfig +objectClass: olcFrontendConfig +olcDatabase: frontend + +dn: olcDatabase=config,cn=config +objectClass: olcDatabaseConfig +olcDatabase: config +olcAccess: to * by dn.base="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" manage by * none +# +# Sample global access control policy: +# Root DSE: allow anyone to read it +# Subschema (sub)entry DSE: allow anyone to read it +# Other DSEs: +# Allow self write access +# Allow authenticated users read access +# Allow anonymous users to authenticate +# +#olcAccess: to dn.base="" by * read +#olcAccess: to dn.base="cn=Subschema" by * read +#olcAccess: to * +# by self write +# by users read +# by anonymous auth +# +# if no access controls are present, the default policy +# allows anyone and everyone to read anything but restricts +# updates to rootdn. (e.g., "access to * by * read") +# +# rootdn can always read and write EVERYTHING! +# + + +####################################################################### +# LMDB database definitions +####################################################################### +# +dn: olcDatabase=mdb,cn=config +objectClass: olcDatabaseConfig +objectClass: olcMdbConfig +olcDatabase: mdb +olcSuffix: {{ ldap_dn }} +olcRootDN: {{ ldap_rootdn }} +# +# Cleartext passwords, especially for the rootdn, should +# be avoided. See slappasswd(8) and slapd-config(5) for details. +# Use of strong authentication encouraged. +olcRootPW: {{ ldap_root_pass_hash }} +# +# The database directory MUST exist prior to running slapd AND +# should only be accessible by the slapd and slap tools. +# Mode 700 recommended. +olcDbDirectory: {{ ldap_container_data_dir }} +# +# Indices to maintain +olcDbIndex: objectClass eq +{% for index in ldap_additional_indices %} +olcDbIndex {{ index }} +{% endfor %} diff --git a/roles/ldap/tests/inventory b/roles/ldap/tests/inventory new file mode 100644 index 0000000..49d4fe2 --- /dev/null +++ b/roles/ldap/tests/inventory @@ -0,0 +1,2 @@ +localhost ansible_connection=local + diff --git a/roles/ldap/tests/test.yml b/roles/ldap/tests/test.yml new file mode 100644 index 0000000..08e1661 --- /dev/null +++ b/roles/ldap/tests/test.yml @@ -0,0 +1,34 @@ +--- +- hosts: localhost + remote_user: root + roles: + - ldap + vars: + ldap_domain: 'dep-b.example.org' + ldap_org: "Department B of the Example Organization" + ldap_root_pass: admin + ldap_root_pass_hash: admin + ldap_base_path: /tmp/ldap + post_tasks: + - name: Create dummy users in org + ldap_entry: + dn: "uid={{ item }},ou=users,{{ ldap_dn }}" + objectClass: + - inetOrgPerson + attributes: + uid: "{{ item }}" + givenName: "firstname" + sn: "surname" + cn: "firstname lastname" + mail: "{{ item }}@mail.{{ ldap_domain }}" + userPassword: "{SSHA}NdjwrLbBHcs9JfWRoz//91CSDRYpmKvx" #password + state: present + bind_dn: "{{ ldap_rootdn }}" + bind_pw: "{{ ldap_root_pass }}" + server_uri: "ldap:///" + loop: + - testUserA + - testUserB + - testUserC + - testManagerA + - testManagerB diff --git a/roles/ldap/vars/main.yml b/roles/ldap/vars/main.yml new file mode 100644 index 0000000..d2a3072 --- /dev/null +++ b/roles/ldap/vars/main.yml @@ -0,0 +1,30 @@ +--- + +ldap_container_socket_dir: "/var/run/sockets" +ldap_container_conf_dir: "/etc/openldap/slapd.d" +ldap_container_data_dir: "/var/lib/openldap/openldap-data" +ldap_container_slapd_file: "/etc/openldap/slapd.ldif" +ldap_container_schema_path: "/etc/openldap/schema" + +ldap_base_schemas_to_load: + - name: cosine.ldif + - name: inetorgperson.ldif +ldap_schemas_to_load: >- + {{ ldap_base_schemas_to_load }} + + {{ ldap_additional_schemas }} + + +ldap_container_volumes: + - "{{ ldap_config_path }}:{{ ldap_container_conf_dir }}:z" + - "{{ ldap_data_path }}:{{ ldap_container_data_dir}}:z" + - "{{ ldap_sock_path }}:{{ ldap_container_socket_dir }}:z" + +ldap_container_labels_base: + version: "{{ ldap_container_version }}" +ldap_container_labels_complete: "{{ ldap_container_labels_base | combine(ldap_container_labels) }}" + + +# phpLDAPadmin container labels +ldap_management_container_labels_base: + version: "{{ ldap_management_version }}" +ldap_management_container_labels_complete: "{{ ldap_management_container_labels_base | combine(ldap_management_container_labels) }}"