Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion dejacode/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
55 changes: 55 additions & 0 deletions dejacode_toolkit/ldap.py
Original file line number Diff line number Diff line change
@@ -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)
68 changes: 68 additions & 0 deletions dje/tests/test_ldap_config.py
Original file line number Diff line number Diff line change
@@ -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, "", "")
36 changes: 36 additions & 0 deletions docs/application-settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
---------------

Expand Down
Loading