diff --git a/dejacode/settings.py b/dejacode/settings.py index 2bc493bf..e210bd42 100644 --- a/dejacode/settings.py +++ b/dejacode/settings.py @@ -18,6 +18,8 @@ from django_auth_ldap.config import GroupOfNamesType from django_auth_ldap.config import LDAPSearch +from dejacode_toolkit.ldap import build_user_search + # The home directory of the dejacode user that owns the installation. PROJECT_DIR = environ.Path(__file__) - 1 ROOT_DIR = PROJECT_DIR - 1 @@ -775,7 +777,37 @@ def get_fake_redis_connection(config, use_strict_redis): # AUTH_LDAP_USER_FILTERSTR="(uid=%(user)s)" AUTH_LDAP_USER_FILTERSTR = env.str("AUTH_LDAP_USER_FILTERSTR", default="") -AUTH_LDAP_USER_SEARCH = LDAPSearch(AUTH_LDAP_USER_DN, ldap.SCOPE_SUBTREE, AUTH_LDAP_USER_FILTERSTR) +# Optional: Define multiple LDAP user searches using a JSON list. +# When provided, this setting overrides AUTH_LDAP_USER_DN and AUTH_LDAP_USER_FILTERSTR. +# +# Example: +# AUTH_LDAP_USER_SEARCHES = """ +# [ +# { +# "base": "ou=users,dc=example,dc=com", +# "filter": "(uid=%(user)s)" +# }, +# { +# "base": "ou=otherusers,dc=example,dc=com", +# "filter": "(uid=%(user)s)" +# } +# ] +# """ +# +# Hint: use as a single line string within docker env +# +# Each entry must define: +# - "base": The base DN to search +# - "filter": The LDAP filter (must include %(user)s) +# +# All searches are combined using LDAPSearchUnion. +AUTH_LDAP_USER_SEARCHES = env.str("AUTH_LDAP_USER_SEARCHES", default="") + +AUTH_LDAP_USER_SEARCH = build_user_search( + AUTH_LDAP_USER_SEARCHES, + AUTH_LDAP_USER_DN, + AUTH_LDAP_USER_FILTERSTR, +) # When AUTH_LDAP_AUTOCREATE_USER is True (default), a new DejaCode user will be # created in the database with the minimum permission (a read-only user). diff --git a/dejacode_toolkit/ldap.py b/dejacode_toolkit/ldap.py new file mode 100644 index 00000000..5fc2d7f3 --- /dev/null +++ b/dejacode_toolkit/ldap.py @@ -0,0 +1,55 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + +import json + +from django.core.exceptions import ImproperlyConfigured + +import ldap +from django_auth_ldap.config import LDAPSearch +from django_auth_ldap.config import LDAPSearchUnion + + +def build_user_search(user_searches, user_dn, user_filterstr): + """ + Return the ``AUTH_LDAP_USER_SEARCH`` object. + + When ``user_searches`` (a raw JSON string) is provided, parse and validate + it and return an ``LDAPSearchUnion``. Otherwise, fall back to a single + ``LDAPSearch`` built from ``user_dn`` and ``user_filterstr``. + """ + if not user_searches: + return LDAPSearch(user_dn, ldap.SCOPE_SUBTREE, user_filterstr) + + try: + definitions = json.loads(user_searches) + except json.JSONDecodeError as e: + raise ImproperlyConfigured(f"Invalid JSON in AUTH_LDAP_USER_SEARCHES: {e}") from e + + if not isinstance(definitions, list): + raise ImproperlyConfigured("AUTH_LDAP_USER_SEARCHES must be a JSON list") + + if not definitions: + raise ImproperlyConfigured("AUTH_LDAP_USER_SEARCHES cannot be empty") + + searches = [] + for index, entry in enumerate(definitions): + if not isinstance(entry, dict): + raise ImproperlyConfigured(f"AUTH_LDAP_USER_SEARCHES[{index}] must be a JSON object") + + base_dn = entry.get("base") + filterstr = entry.get("filter") + + if not base_dn or not filterstr: + raise ImproperlyConfigured( + f"AUTH_LDAP_USER_SEARCHES[{index}] must define 'base' and 'filter'" + ) + + searches.append(LDAPSearch(base_dn, ldap.SCOPE_SUBTREE, filterstr)) + + return LDAPSearchUnion(*searches) diff --git a/dje/tests/test_ldap_config.py b/dje/tests/test_ldap_config.py new file mode 100644 index 00000000..a54500ec --- /dev/null +++ b/dje/tests/test_ldap_config.py @@ -0,0 +1,68 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + +from django.core.exceptions import ImproperlyConfigured +from django.test import SimpleTestCase + +from django_auth_ldap.config import LDAPSearch +from django_auth_ldap.config import LDAPSearchUnion + +from dejacode_toolkit.ldap import build_user_search + + +class BuildUserSearchTestCase(SimpleTestCase): + def test_build_user_search_fallback_to_single_search_when_empty(self): + result = build_user_search("", "ou=users,dc=example,dc=com", "(uid=%(user)s)") + self.assertIsInstance(result, LDAPSearch) + + def test_build_user_search_valid_json_returns_union(self): + raw = ( + '[{"base": "ou=a,dc=example,dc=com", "filter": "(uid=%(user)s)"},' + ' {"base": "ou=b,dc=example,dc=com", "filter": "(uid=%(user)s)"}]' + ) + result = build_user_search(raw, "", "") + self.assertIsInstance(result, LDAPSearchUnion) + + def test_build_user_search_single_entry_still_returns_union(self): + raw = '[{"base": "ou=a,dc=example,dc=com", "filter": "(uid=%(user)s)"}]' + result = build_user_search(raw, "", "") + self.assertIsInstance(result, LDAPSearchUnion) + + def test_build_user_search_invalid_json_raises(self): + with self.assertRaisesMessage(ImproperlyConfigured, "Invalid JSON"): + build_user_search("{not json", "", "") + + def test_build_user_search_not_a_list_raises(self): + with self.assertRaisesMessage(ImproperlyConfigured, "must be a JSON list"): + build_user_search('{"base": "x", "filter": "y"}', "", "") + + def test_build_user_search_empty_list_raises(self): + with self.assertRaisesMessage(ImproperlyConfigured, "cannot be empty"): + build_user_search("[]", "", "") + + def test_build_user_search_entry_not_object_raises(self): + with self.assertRaisesMessage(ImproperlyConfigured, "[0] must be a JSON object"): + build_user_search('["not an object"]', "", "") + + def test_build_user_search_missing_base_raises(self): + raw = '[{"filter": "(uid=%(user)s)"}]' + with self.assertRaisesMessage(ImproperlyConfigured, "[0] must define 'base' and 'filter'"): + build_user_search(raw, "", "") + + def test_build_user_search_missing_filter_raises(self): + raw = '[{"base": "ou=a,dc=example,dc=com"}]' + with self.assertRaisesMessage(ImproperlyConfigured, "[0] must define 'base' and 'filter'"): + build_user_search(raw, "", "") + + def test_build_user_search_error_index_points_to_bad_entry(self): + raw = ( + '[{"base": "ou=a,dc=example,dc=com", "filter": "(uid=%(user)s)"},' + ' {"base": "ou=b,dc=example,dc=com"}]' + ) + with self.assertRaisesMessage(ImproperlyConfigured, "[1] must define 'base' and 'filter'"): + build_user_search(raw, "", "") diff --git a/docs/application-settings.rst b/docs/application-settings.rst index d7e1903f..943c9dfe 100644 --- a/docs/application-settings.rst +++ b/docs/application-settings.rst @@ -383,6 +383,42 @@ It must return exactly one result for authentication to succeed. AUTH_LDAP_USER_DN="ou=users,dc=example,dc=com" AUTH_LDAP_USER_FILTERSTR="(uid=%(user)s)" +AUTH_LDAP_USER_SEARCHES +----------------------- + +To search across multiple LDAP locations, use +``AUTH_LDAP_USER_SEARCHES``. + +When this setting is provided, it overrides both +``AUTH_LDAP_USER_DN`` and ``AUTH_LDAP_USER_FILTERSTR``. + +The setting must contain a JSON list of search definitions. Each entry +must define: + +- ``base``: The base DN to search +- ``filter``: The LDAP filter (must include ``%(user)s``) + +All configured searches are combined using ``LDAPSearchUnion``. + +Example: + +.. code-block:: python + + AUTH_LDAP_USER_SEARCHES = """ + [ + { + "base": "ou=users,dc=example,dc=com", + "filter": "(uid=%(user)s)" + }, + { + "base": "ou=otherusers,dc=example,dc=com", + "filter": "(uid=%(user)s)" + } + ] + """ + +For usage in a ``docker.env`` file only a single line string is accepted. + AUTOCREATE_USER ---------------