diff --git a/lumi2/__init__.py b/lumi2/__init__.py index 5180a3c..d615a31 100644 --- a/lumi2/__init__.py +++ b/lumi2/__init__.py @@ -19,9 +19,9 @@ def create_app(test_config=None): LDAP_HOSTNAME='ldap://openldap', LDAP_BIND_USER_DN='cn=admin,dc=example,dc=com', LDAP_BIND_USER_PASSWORD='admin', - LDAP_BASE_DN='cn=example,cn=com', - LDAP_USER_SEARCH_BASE='ou=users,cn=example,cn=com', - LDAP_GROUPS_SEARCH_BASE='ou=groups,cn=example,cn=com', + LDAP_BASE_DN='dc=example,dc=com', + LDAP_USERS_OU='ou=users,dc=example,dc=com', + LDAP_GROUPS_OU='ou=groups,dc=example,dc=com', LDAP_USER_OBJECT_CLASS='inetOrgPerson', LDAP_GROUP_OBJECT_CLASS='groupOfUniqueNames', ) diff --git a/lumi2/ldap.py b/lumi2/ldap.py index b3c612e..81ce5b0 100644 --- a/lumi2/ldap.py +++ b/lumi2/ldap.py @@ -1,27 +1,39 @@ -"""Interactions with an OpenLDAP server. +"""This module is an API used to interact with an OpenLDAP server. Interactions include setting up authenticated connections, querying the DIT and creating/reading/updating/deleting DIT entries. -All function calls within this module rely heavily on the `ldap3 module `_. +Functions within this module rely heavily on the ldap3 module: +https://ldap3.readthedocs.io/en/latest/ """ from string import ascii_lowercase, ascii_uppercase, digits from flask import current_app -from ldap3 import Connection, Server, ALL +from ldap3 import Connection, Server, ALL, Reader, Writer, ObjectDef + +from lumi2.exceptions import MissingConfigKeyError -from exceptions import MissingConfigKeyError class InvalidStringFormatException(Exception): """Exception raised when an invalid string format is encountered.""" - pass + +class InvalidConnectionException(Exception): + """Exception raised when an invalid Connection is encountered.""" + pass + + +class EntryExistsException(Exception): + """Exception raised when an entry unexpectedly already exists.""" + pass + + def _assert_is_valid_base_dn(input_str) -> None: """Checks whether the input string is a valid LDAP base DN. - A valid base DN is a string of the format 'cn=,cn=', whereby: + A valid base DN is a string of the format 'dc=,dc=', whereby: (second level domain) is a substring containing only lowercase alphanumeric characters with minimum length 1. (top level domain) is a substring containing only lowercase latin @@ -51,12 +63,13 @@ def _assert_is_valid_base_dn(input_str) -> None: if len(tokens) != 2: raise InvalidStringFormatException("Expected exactly one ',' character.") for token in tokens: - if not token.startswith("cn="): + if not token.startswith("dc="): raise InvalidStringFormatException( - "Expected DN component to start with 'cn='." + "Expected DN component to start with 'dc='." ) - token_sld, token_tld = tokens + token_sld = tokens[0][3:] + token_tld = tokens[1][3:] if not len(token_sld): raise InvalidStringFormatException( "Expected at least one character in second level domain." @@ -79,10 +92,11 @@ def _assert_is_valid_base_dn(input_str) -> None: f"domain but got: '{char}'." ) -def _assert_is_valid_search_base(input_str) -> None: - """Checks whether the input string is a valid LDAP search base string. - A valid search base is a string of the format 'ou=,', whereby: +def _assert_is_valid_ou_dn(input_str) -> None: + """Checks whether the input string is a valid organizational unit DN. + + A valid ou DN is a string of the format 'ou=,', whereby: (name of organizational unit) is a substring containing only alphanumeric characters with minimum length 1. is a substring representing an LDAP base DN as expected by @@ -102,7 +116,7 @@ def _assert_is_valid_search_base(input_str) -> None: TypeError If input_str is not of type string. InvalidStringFormatException - If input_str is not a valid LDAP search base string. + If input_str is not a valid LDAP ou DN string. """ if not isinstance(input_str, str): @@ -114,7 +128,9 @@ def _assert_is_valid_search_base(input_str) -> None: if not tokens[0].startswith("ou="): raise InvalidStringFormatException("Expected 'ou='.") - token_ou = tokens[0][:3] + token_ou = tokens[0][3:] + if not len(token_ou): + raise InvalidStringFormatException("OU name cannot be empty.") token_base_dn = tokens[1] + "," + tokens[2] _assert_is_valid_base_dn(token_base_dn) @@ -125,6 +141,7 @@ def _assert_is_valid_search_base(input_str) -> None: f"but got: '{char}'." ) + def _assert_is_valid_user_object_class(input_str) -> None: """Checks whether the input string is a valid LDAP user object class. @@ -155,6 +172,7 @@ def _assert_is_valid_user_object_class(input_str) -> None: f"Expected 'inetOrgPerson' but got: '{input_str}'." ) + def _assert_is_valid_group_object_class(input_str) -> None: """Checks whether the input string is a valid LDAP group object class. @@ -185,6 +203,7 @@ def _assert_is_valid_group_object_class(input_str) -> None: f"Expected 'groupOfUniqueNames' but got: '{input_str}'." ) + def _assert_is_valid_bind_user_dn(input_str) -> None: """Checks whether the input string is a valid LDAP bind user DN. @@ -232,6 +251,7 @@ def _assert_is_valid_bind_user_dn(input_str) -> None: f"but got: '{char}'." ) + def _assert_app_config_is_valid() -> None: """Checks the app config's LDAP settings for completeness and correctness. @@ -242,8 +262,8 @@ def _assert_app_config_is_valid() -> None: Ensures that the following config keys are present and contain valid values: - 'LDAP_BIND_USER_DN' - 'LDAP_BASE_DN' - - 'LDAP_USER_SEARCH_BASE' - - 'LDAP_GROUPS_SEARCH_BASE' + - 'LDAP_USERS_OU' + - 'LDAP_GROUPS_OU' - 'LDAP_USER_OBJECT_CLASS' - 'LDAP_GROUP_OBJECT_CLASS' @@ -266,8 +286,8 @@ def _assert_app_config_is_valid() -> None: 'LDAP_BIND_USER_DN', 'LDAP_BIND_USER_PASSWORD', 'LDAP_BASE_DN', - 'LDAP_USER_SEARCH_BASE', - 'LDAP_GROUPS_SEARCH_BASE', + 'LDAP_USERS_OU', + 'LDAP_GROUPS_OU', 'LDAP_USER_OBJECT_CLASS', 'LDAP_GROUP_OBJECT_CLASS', ] @@ -284,38 +304,33 @@ def _assert_app_config_is_valid() -> None: _assert_is_valid_base_dn(current_app.config['LDAP_BASE_DN']) _assert_is_valid_bind_user_dn(current_app.config['LDAP_BIND_USER_DN']) - for base in ['LDAP_USER_SEARCH_BASE', 'LDAP_GROUPS_SEARCH_BASE']: - _assert_is_valid_search_base(current_app.config[base]) + for base in ['LDAP_USERS_OU', 'LDAP_GROUPS_OU']: + _assert_is_valid_ou_dn(current_app.config[base]) _assert_is_valid_user_object_class(current_app.config['LDAP_USER_OBJECT_CLASS']) _assert_is_valid_group_object_class(current_app.config['LDAP_GROUP_OBJECT_CLASS']) -def get_authenticated_connection( - hostname=current_app.config['LDAP_HOSTNAME'], - user=current_app.config['LDAP_BIND_USER_DN'], - password=current_app.config['LDAP_BIND_USER_PASSWORD'], - ) -> Connection: - """Returns a Connection object to the LDAP server using bind credentials. - The bind credentials and server hostname are read from the :mod:`core.settings` - module. +def get_connection() -> Connection: + """Returns a Connection to the LDAP server specified in the app config. - Attributes - ---------- - hostname : str - Hostname at which the LDAP server can be reached. - user : str - DN of the bind user used to authenticate to the server. - password : str - Password of the bind user authenticating to the server. + A connection attempt is made to the LDAP server at the specified hostname + using the specified credentials. + + Returns + ------- + ldap3.Connection + A bound (i.e. authenticated), active Connection object to the server. Raises ------ - :class:`ldap3.core.exceptions.LDAPSocketOpenError` - If the server specified by the ``hostname`` cannot be reached. - :class:`ldap3.core.exceptions.LDAPBindError` - If the bind credentials ``user`` and/or ``password`` are - invalid. + ldap3.core.exceptions.LDAPSocketOpenError + If the server specified by the hostname cannot be reached. + ldap3.core.exceptions.LDAPBindError + If the bind credentials (user DN and/or password) are invalid. """ + hostname = current_app.config['LDAP_HOSTNAME'] + user = current_app.config['LDAP_BIND_USER_DN'] + password = current_app.config['LDAP_BIND_USER_PASSWORD'] return Connection( Server(hostname, get_info=ALL), @@ -323,3 +338,89 @@ def get_authenticated_connection( password=password, auto_bind=True, ) + + +def _assert_is_valid_connection(connection: Connection) -> None: + """Ensures that the connection is valid, open and bound. + + Parameters + ---------- + Connection : ldap3.Connection + Connection object to an LDAP server. + + Raises + ------ + TypeError + If connection is not of type ldap3.Connection + InvalidConnectionException + If the connection is not bound. + If the connection is closed. + """ + + if not isinstance(connection, Connection): + raise TypeError(f"Expected a Connection object but got '{type(connection)}'.") + if not connection.open: + raise InvalidConnectionException("Connection is not open.") + if not connection.bound: + raise InvalidConnectionException("Connection is not bound.") + + +def ou_exists(connection: Connection, ou_dn: str) -> bool: + """Checks whether the specified ou entry currently exists in the DIT. + + The DN must be a direct child of the DIT's base DN. + The check is carried out by querying the LDAP server for the existence of + an entry at the specified DN. + + Parameters + ---------- + Connection : ldap3.Connection + Bound Connection object to an LDAP server. + ou_dn : str + DN of an organizational unit (ou) directly below the DIT's root entry. + + Returns + ------- + Bool + True if the specified ou exists in the DIT, and False otherwise. + """ + + _assert_is_valid_connection(connection) + _assert_is_valid_ou_dn(ou_dn) + + ou_name = ou_dn.split(',')[0][3:] + ou_objectdef = ObjectDef('organizationalUnit', connection) + reader = Reader(connection, ou_objectdef, current_app.config['LDAP_BASE_DN']) + search_results = reader.search() + + for result in search_results: + if result.ou == ou_name: + return True + return False + + +def create_ou(connection: Connection, ou_dn: str) -> None: + """Creates an entry for the specified organizational unit. + + The ou entry must be a direct child of the DIT's base DN. + + Parameters + ---------- + Connection : ldap3.Connection + Bound Connection object to an LDAP server. + ou_dn : str + DN of an organizational unit (ou) directly below the DIT's root entry. + + Raises + ------ + EntryExistsException + If an entry for the specified ou already exists in the DIT. + """ + + _assert_is_valid_connection(connection) + _assert_is_valid_ou_dn(ou_dn) + + if ou_exists(connection, ou_dn): + raise EntryExistsException(f"Cannot create '{ou_dn}': entry already exists.") + + connection.add(ou_dn, 'organizationalUnit')