From a271d71aa618f06a8266c55b8e63aeea35a50baf Mon Sep 17 00:00:00 2001 From: Nicolas <32845761+nicoklaus@users.noreply.github.com> Date: Wed, 13 May 2026 12:22:45 +0000 Subject: [PATCH 1/6] feat: add support for django LDAPSearchUnion Signed-off-by: Nicolas <32845761+nicoklaus@users.noreply.github.com> --- dejacode/settings.py | 67 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/dejacode/settings.py b/dejacode/settings.py index eb1040cf..d5c246d9 100644 --- a/dejacode/settings.py +++ b/dejacode/settings.py @@ -767,7 +767,72 @@ 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="") + +if AUTH_LDAP_USER_SEARCHES: + import json + from django_auth_ldap.config import LDAPSearchUnion + + try: + ldap_search_definitions = json.loads(AUTH_LDAP_USER_SEARCHES) + except json.JSONDecodeError as e: + raise ValueError("Invalid JSON in AUTH_LDAP_USER_SEARCHES") from e + + if not isinstance(ldap_search_definitions, list): + raise ValueError("AUTH_LDAP_USER_SEARCHES must be a JSON list") + + ldap_searches = [] + for search_definition in ldap_search_definitions: + if not isinstance(search_definition, dict): + raise ValueError("Each entry must be an object") + + base_dn = search_definition.get("base") + filterstr = search_definition.get("filter") + + if not base_dn or not filterstr: + raise ValueError("Each LDAP search entry must define 'base' and 'filter'") + + ldap_searches.append( + LDAPSearch(base_dn, ldap.SCOPE_SUBTREE, filterstr) + ) + + if not ldap_searches: + raise ValueError("AUTH_LDAP_USER_SEARCHES cannot be empty") + + # Always use LDAPSearchUnion, even for a single search entry. + AUTH_LDAP_USER_SEARCH = LDAPSearchUnion(*ldap_searches) + +else: + # Fallback to single LDAP search configuration. + AUTH_LDAP_USER_SEARCH = LDAPSearch( + AUTH_LDAP_USER_DN, + ldap.SCOPE_SUBTREE, + 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). From 498db9184f87527a2b2c7307f7516e79f14f0680 Mon Sep 17 00:00:00 2001 From: Nicolas <32845761+nicoklaus@users.noreply.github.com> Date: Wed, 13 May 2026 12:23:01 +0000 Subject: [PATCH 2/6] doc: add documentation for USER_SEARCHES Signed-off-by: Nicolas <32845761+nicoklaus@users.noreply.github.com> --- docs/application-settings.rst | 36 +++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/application-settings.rst b/docs/application-settings.rst index d7e1903f..9a55b36e 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)" +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 --------------- From 1410e991be01f5235646d82aacc64671cac19a9a Mon Sep 17 00:00:00 2001 From: Nicolas <32845761+nicoklaus@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:38:37 +0200 Subject: [PATCH 3/6] doc: rename USER_SEARCHES to AUTH_LDAP_USER_SEARCHES Signed-off-by: Nicolas <32845761+nicoklaus@users.noreply.github.com> --- docs/application-settings.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/application-settings.rst b/docs/application-settings.rst index 9a55b36e..943c9dfe 100644 --- a/docs/application-settings.rst +++ b/docs/application-settings.rst @@ -383,8 +383,8 @@ 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)" -USER_SEARCHES -------------- +AUTH_LDAP_USER_SEARCHES +----------------------- To search across multiple LDAP locations, use ``AUTH_LDAP_USER_SEARCHES``. From ef9ff07c1e3e5c5471922e889353830c7529fe4c Mon Sep 17 00:00:00 2001 From: Nicolas <32845761+nicoklaus@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:40:21 +0200 Subject: [PATCH 4/6] feat: extract ldap search parsing and validation to dejacode_toolkit/ldap.py Signed-off-by: Nicolas <32845761+nicoklaus@users.noreply.github.com> --- dejacode/settings.py | 48 +++++---------------------------- dejacode_toolkit/ldap.py | 57 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 41 deletions(-) create mode 100644 dejacode_toolkit/ldap.py diff --git a/dejacode/settings.py b/dejacode/settings.py index d5c246d9..9f4ccde1 100644 --- a/dejacode/settings.py +++ b/dejacode/settings.py @@ -17,6 +17,7 @@ import ldap 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 @@ -769,7 +770,7 @@ def get_fake_redis_connection(config, use_strict_redis): # 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 = """ # [ @@ -793,46 +794,11 @@ def get_fake_redis_connection(config, use_strict_redis): # All searches are combined using LDAPSearchUnion. AUTH_LDAP_USER_SEARCHES = env.str("AUTH_LDAP_USER_SEARCHES", default="") -if AUTH_LDAP_USER_SEARCHES: - import json - from django_auth_ldap.config import LDAPSearchUnion - - try: - ldap_search_definitions = json.loads(AUTH_LDAP_USER_SEARCHES) - except json.JSONDecodeError as e: - raise ValueError("Invalid JSON in AUTH_LDAP_USER_SEARCHES") from e - - if not isinstance(ldap_search_definitions, list): - raise ValueError("AUTH_LDAP_USER_SEARCHES must be a JSON list") - - ldap_searches = [] - for search_definition in ldap_search_definitions: - if not isinstance(search_definition, dict): - raise ValueError("Each entry must be an object") - - base_dn = search_definition.get("base") - filterstr = search_definition.get("filter") - - if not base_dn or not filterstr: - raise ValueError("Each LDAP search entry must define 'base' and 'filter'") - - ldap_searches.append( - LDAPSearch(base_dn, ldap.SCOPE_SUBTREE, filterstr) - ) - - if not ldap_searches: - raise ValueError("AUTH_LDAP_USER_SEARCHES cannot be empty") - - # Always use LDAPSearchUnion, even for a single search entry. - AUTH_LDAP_USER_SEARCH = LDAPSearchUnion(*ldap_searches) - -else: - # Fallback to single LDAP search configuration. - AUTH_LDAP_USER_SEARCH = LDAPSearch( - AUTH_LDAP_USER_DN, - ldap.SCOPE_SUBTREE, - AUTH_LDAP_USER_FILTERSTR, - ) +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..86e9f2a1 --- /dev/null +++ b/dejacode_toolkit/ldap.py @@ -0,0 +1,57 @@ +# +# 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 + +import ldap +from django.core.exceptions import ImproperlyConfigured +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) From 79f5016b383f2bcab9c89a61a6e124abffe721cb Mon Sep 17 00:00:00 2001 From: Nicolas <32845761+nicoklaus@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:42:28 +0200 Subject: [PATCH 5/6] feat: add unit test for LDAPSearch Signed-off-by: Nicolas <32845761+nicoklaus@users.noreply.github.com> --- dje/tests/test_ldap_config.py | 74 +++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 dje/tests/test_ldap_config.py diff --git a/dje/tests/test_ldap_config.py b/dje/tests/test_ldap_config.py new file mode 100644 index 00000000..0a47b29c --- /dev/null +++ b/dje/tests/test_ldap_config.py @@ -0,0 +1,74 @@ +# +# 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.ldap_config 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, "", "") From 6e5426e8040e2c3bd5323841959d2435ea4efce8 Mon Sep 17 00:00:00 2001 From: Nicolas <32845761+nicoklaus@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:50:13 +0200 Subject: [PATCH 6/6] fix: linting issues Signed-off-by: Nicolas <32845761+nicoklaus@users.noreply.github.com> --- dejacode/settings.py | 1 + dejacode_toolkit/ldap.py | 14 ++++++-------- dje/tests/test_ldap_config.py | 14 ++++---------- 3 files changed, 11 insertions(+), 18 deletions(-) diff --git a/dejacode/settings.py b/dejacode/settings.py index e4d8a832..e210bd42 100644 --- a/dejacode/settings.py +++ b/dejacode/settings.py @@ -17,6 +17,7 @@ import ldap 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. diff --git a/dejacode_toolkit/ldap.py b/dejacode_toolkit/ldap.py index 86e9f2a1..5fc2d7f3 100644 --- a/dejacode_toolkit/ldap.py +++ b/dejacode_toolkit/ldap.py @@ -8,14 +8,16 @@ import json -import ldap 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. + """ + 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 @@ -27,9 +29,7 @@ def build_user_search(user_searches, user_dn, 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 + 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") @@ -40,9 +40,7 @@ def build_user_search(user_searches, user_dn, user_filterstr): 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" - ) + raise ImproperlyConfigured(f"AUTH_LDAP_USER_SEARCHES[{index}] must be a JSON object") base_dn = entry.get("base") filterstr = entry.get("filter") diff --git a/dje/tests/test_ldap_config.py b/dje/tests/test_ldap_config.py index 0a47b29c..a54500ec 100644 --- a/dje/tests/test_ldap_config.py +++ b/dje/tests/test_ldap_config.py @@ -12,7 +12,7 @@ from django_auth_ldap.config import LDAPSearch from django_auth_ldap.config import LDAPSearchUnion -from dejacode.ldap_config import build_user_search +from dejacode_toolkit.ldap import build_user_search class BuildUserSearchTestCase(SimpleTestCase): @@ -51,16 +51,12 @@ def test_build_user_search_entry_not_object_raises(self): def test_build_user_search_missing_base_raises(self): raw = '[{"filter": "(uid=%(user)s)"}]' - with self.assertRaisesMessage( - ImproperlyConfigured, "[0] must define 'base' and 'filter'" - ): + 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'" - ): + 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): @@ -68,7 +64,5 @@ def test_build_user_search_error_index_points_to_bad_entry(self): '[{"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'" - ): + with self.assertRaisesMessage(ImproperlyConfigured, "[1] must define 'base' and 'filter'"): build_user_search(raw, "", "")