diff --git a/docs/docs/start/advanced.md b/docs/docs/start/advanced.md index 9bacc83924fd..25f6832365d3 100644 --- a/docs/docs/start/advanced.md +++ b/docs/docs/start/advanced.md @@ -65,7 +65,7 @@ Next you can start configuring the connection. Either use the config file or set | `INVENTREE_LDAP_USER_ATTR_MAP` | `ldap.user_attr_map` | LDAP <-> InvenTree user attribute map, can be json if used as env, in yml directly specify the object. default: `{"first_name": "givenName", "last_name": "sn", "email": "mail"}` | | `INVENTREE_LDAP_ALWAYS_UPDATE_USER` | `ldap.always_update_user` | Always update the user on each login, default: `true` | | `INVENTREE_LDAP_CACHE_TIMEOUT` | `ldap.cache_timeout` | cache timeout to reduce traffic with LDAP server, default: `3600` (1h) | -| `INVENTREE_LDAP_GROUP_SEARCH` | `ldap.group_search` | Base LDAP DN for group searching; required to enable group features | +| `INVENTREE_LDAP_GROUP_SEARCH` | `ldap.group_search` | Base LDAP DN for group searching; required to enable group features. If not set, all group-based features are disabled gracefully | | `INVENTREE_LDAP_GROUP_OBJECT_CLASS` | `ldap.group_object_class` | The string to pass to the LDAP group search `(objectClass=<...>)`, default: `groupOfUniqueNames` | | `INVENTREE_LDAP_MIRROR_GROUPS` | `ldap.mirror_groups` | If `True`, mirror a user's LDAP group membership in the Django database, default: `False` | | `INVENTREE_LDAP_GROUP_TYPE_CLASS` | `ldap.group_type_class` | The group class to be imported from `django_auth_ldap.config` as a string, default: `'GroupOfUniqueNamesType'`| @@ -74,6 +74,7 @@ Next you can start configuring the connection. Either use the config file or set | `INVENTREE_LDAP_REQUIRE_GROUP` | `ldap.require_group` | If set, users _must_ be in this group to log in to InvenTree | | `INVENTREE_LDAP_DENY_GROUP` | `ldap.deny_group` | If set, users _must not_ be in this group to log in to InvenTree | | `INVENTREE_LDAP_USER_FLAGS_BY_GROUP` | `ldap.user_flags_by_group` | LDAP group to InvenTree user flag map, can be json if used as env, in yml directly specify the object. See config template for example, default: `{}` | +| `INVENTREE_LDAP_FIND_GROUP_PERMS` | `ldap.find_group_perms` | If `True`, look up LDAP group permissions during authentication. Automatically disabled when `ldap.group_search` is not set. default: `True` | ## Tracing support diff --git a/src/backend/InvenTree/InvenTree/setting/ldap.py b/src/backend/InvenTree/InvenTree/setting/ldap.py index 3b3917970182..832081cd6858 100644 --- a/src/backend/InvenTree/InvenTree/setting/ldap.py +++ b/src/backend/InvenTree/InvenTree/setting/ldap.py @@ -68,6 +68,33 @@ def get_ldap_config(debug: bool = False) -> dict: str, ) + group_search_dn = get_setting( + 'INVENTREE_LDAP_GROUP_SEARCH', 'ldap.group_search' + ) + + find_group_perms = get_boolean_setting( + 'INVENTREE_LDAP_FIND_GROUP_PERMS', 'ldap.find_group_perms', True + ) + + # If group search DN is not configured, group-based features cannot + # work. Disable them gracefully with a warning instead of letting + # django-auth-ldap crash at runtime with a TypeError (see #12225). + if group_search_dn is None: + if find_group_perms or get_setting( + 'INVENTREE_LDAP_MIRROR_GROUPS', 'ldap.mirror_groups' + ) or get_setting( + 'INVENTREE_LDAP_REQUIRE_GROUP', 'ldap.require_group' + ) or get_setting( + 'INVENTREE_LDAP_DENY_GROUP', 'ldap.deny_group' + ): + print( + '[LDAP] ldap.group_search is not configured; ' + 'disabling group-based features (find_group_perms, ' + 'mirror_groups, require_group, deny_group). ' + 'Set ldap.group_search to enable them.' + ) + find_group_perms = False + ldap_config = { 'AUTH_LDAP_GLOBAL_OPTIONS': global_options, 'AUTH_LDAP_SERVER_URI': get_setting( @@ -108,13 +135,13 @@ def get_ldap_config(debug: bool = False) -> dict: ), 'AUTH_LDAP_MIRROR_GROUPS': get_boolean_setting( 'INVENTREE_LDAP_MIRROR_GROUPS', 'ldap.mirror_groups', False - ), + ) if group_search_dn is not None else False, 'AUTH_LDAP_GROUP_OBJECT_CLASS': group_object_class, 'AUTH_LDAP_GROUP_SEARCH': django_auth_ldap.config.LDAPSearch( - get_setting('INVENTREE_LDAP_GROUP_SEARCH', 'ldap.group_search'), + group_search_dn, ldap.SCOPE_SUBTREE, f'(objectClass={group_object_class})', - ), + ) if group_search_dn is not None else None, 'AUTH_LDAP_GROUP_TYPE_CLASS': group_type_class, 'AUTH_LDAP_GROUP_TYPE_CLASS_ARGS': [*group_type_class_args], 'AUTH_LDAP_GROUP_TYPE_CLASS_KWARGS': {**group_type_class_kwargs}, @@ -123,17 +150,17 @@ def get_ldap_config(debug: bool = False) -> dict: ), 'AUTH_LDAP_REQUIRE_GROUP': get_setting( 'INVENTREE_LDAP_REQUIRE_GROUP', 'ldap.require_group' - ), + ) if group_search_dn is not None else None, 'AUTH_LDAP_DENY_GROUP': get_setting( 'INVENTREE_LDAP_DENY_GROUP', 'ldap.deny_group' - ), + ) if group_search_dn is not None else None, 'AUTH_LDAP_USER_FLAGS_BY_GROUP': get_setting( 'INVENTREE_LDAP_USER_FLAGS_BY_GROUP', 'ldap.user_flags_by_group', default_value=None, typecast=dict, - ), - 'AUTH_LDAP_FIND_GROUP_PERMS': True, + ) if group_search_dn is not None else None, + 'AUTH_LDAP_FIND_GROUP_PERMS': find_group_perms, } return ldap_config diff --git a/src/backend/InvenTree/setting/tests/__init__.py b/src/backend/InvenTree/setting/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/backend/InvenTree/setting/tests/test_ldap_config.py b/src/backend/InvenTree/setting/tests/test_ldap_config.py new file mode 100644 index 000000000000..9f5bb6b8409c --- /dev/null +++ b/src/backend/InvenTree/setting/tests/test_ldap_config.py @@ -0,0 +1,145 @@ +"""Tests for LDAP configuration handling. + +Verifies that missing ``ldap.group_search`` does not cause runtime crashes +and that ``find_group_perms`` is properly configurable. +""" + +import importlib +from unittest import mock + +import pytest + + +@pytest.fixture +def ldap_module(): + """Import the ldap setting module fresh for each test.""" + # The module imports django_auth_ldap and ldap at function level, + # so we can import it directly without those packages installed + # as long as we mock them for the get_ldap_config call. + import InvenTree.InvenTree.setting.ldap as ldap_mod + importlib.reload(ldap_mod) + return ldap_mod + + +class TestLdapGroupSearchMissing: + """Tests for graceful handling of missing ldap.group_search (see #12225).""" + + def test_find_group_perms_disabled_when_group_search_missing(self, ldap_module): + """When group_search is None, find_group_perms must be False.""" + with mock.patch.object(ldap_module, 'get_setting') as gs, \ + mock.patch.object(ldap_module, 'get_boolean_setting') as gbs: + + # get_setting: return None for group_search, defaults for others + def gs_side_effect(env_key, yaml_key, *args, **kwargs): + if 'GROUP_SEARCH' in env_key: + return None + if 'GLOBAL_OPTIONS' in env_key: + return kwargs.get('default_value', {}) or {} + if 'GROUP_TYPE_CLASS_ARGS' in env_key: + return kwargs.get('default_value', []) if 'default_value' in kwargs else [] + if 'GROUP_TYPE_CLASS_KWARGS' in env_key: + return kwargs.get('default_value', {}) or {} + return kwargs.get('default_value', None) + + gs.side_effect = gs_side_effect + + # get_boolean_setting: return True for find_group_perms, False for others + def gbs_side_effect(env_key, yaml_key, default): + if 'FIND_GROUP_PERMS' in env_key: + return True + return default + + gbs.side_effect = gbs_side_effect + + # Mock the imports inside get_ldap_config + with mock.patch('django_auth_ldap.config') as mock_config: + mock_config.LDAPSearch.return_value = mock.MagicMock() + mock_config.GroupOfUniqueNamesType.return_value = mock.MagicMock() + + with mock.patch('ldap') as mock_ldap: + mock_ldap.SCOPE_SUBTREE = 2 + mock_ldap.OPT_REFERRALS = 0 + + config = ldap_module.get_ldap_config(debug=False) + + assert config['AUTH_LDAP_FIND_GROUP_PERMS'] is False + assert config['AUTH_LDAP_GROUP_SEARCH'] is None + assert config['AUTH_LDAP_MIRROR_GROUPS'] is False + assert config['AUTH_LDAP_REQUIRE_GROUP'] is None + assert config['AUTH_LDAP_DENY_GROUP'] is None + assert config['AUTH_LDAP_USER_FLAGS_BY_GROUP'] is None + + def test_find_group_perms_enabled_when_group_search_set(self, ldap_module): + """When group_search is configured, find_group_perms stays True.""" + with mock.patch.object(ldap_module, 'get_setting') as gs, \ + mock.patch.object(ldap_module, 'get_boolean_setting') as gbs: + + def gs_side_effect(env_key, yaml_key, *args, **kwargs): + if 'GROUP_SEARCH' in env_key: + return 'ou=groups,dc=example,dc=org' + if 'GLOBAL_OPTIONS' in env_key: + return kwargs.get('default_value', {}) or {} + if 'GROUP_TYPE_CLASS_ARGS' in env_key: + return kwargs.get('default_value', []) if 'default_value' in kwargs else [] + if 'GROUP_TYPE_CLASS_KWARGS' in env_key: + return kwargs.get('default_value', {}) or {} + return kwargs.get('default_value', None) + + gs.side_effect = gs_side_effect + + def gbs_side_effect(env_key, yaml_key, default): + if 'FIND_GROUP_PERMS' in env_key: + return True + return default + + gbs.side_effect = gbs_side_effect + + with mock.patch('django_auth_ldap.config') as mock_config: + mock_config.LDAPSearch.return_value = mock.MagicMock() + mock_config.GroupOfUniqueNamesType.return_value = mock.MagicMock() + + with mock.patch('ldap') as mock_ldap: + mock_ldap.SCOPE_SUBTREE = 2 + + config = ldap_module.get_ldap_config(debug=False) + + assert config['AUTH_LDAP_FIND_GROUP_PERMS'] is True + assert config['AUTH_LDAP_GROUP_SEARCH'] is not None + + def test_find_group_perms_can_be_disabled_explicitly(self, ldap_module): + """User can set find_group_perms=False even when group_search is set.""" + with mock.patch.object(ldap_module, 'get_setting') as gs, \ + mock.patch.object(ldap_module, 'get_boolean_setting') as gbs: + + def gs_side_effect(env_key, yaml_key, *args, **kwargs): + if 'GROUP_SEARCH' in env_key: + return 'ou=groups,dc=example,dc=org' + if 'GLOBAL_OPTIONS' in env_key: + return kwargs.get('default_value', {}) or {} + if 'GROUP_TYPE_CLASS_ARGS' in env_key: + return kwargs.get('default_value', []) if 'default_value' in kwargs else [] + if 'GROUP_TYPE_CLASS_KWARGS' in env_key: + return kwargs.get('default_value', {}) or {} + return kwargs.get('default_value', None) + + gs.side_effect = gs_side_effect + + def gbs_side_effect(env_key, yaml_key, default): + if 'FIND_GROUP_PERMS' in env_key: + return False + return default + + gbs.side_effect = gbs_side_effect + + with mock.patch('django_auth_ldap.config') as mock_config: + mock_config.LDAPSearch.return_value = mock.MagicMock() + mock_config.GroupOfUniqueNamesType.return_value = mock.MagicMock() + + with mock.patch('ldap') as mock_ldap: + mock_ldap.SCOPE_SUBTREE = 2 + + config = ldap_module.get_ldap_config(debug=False) + + assert config['AUTH_LDAP_FIND_GROUP_PERMS'] is False + # Group search is still configured (user might use it for other features) + assert config['AUTH_LDAP_GROUP_SEARCH'] is not None \ No newline at end of file