ldap: Add support for syncing of group descriptions.

Fixes #31040.
This commit is contained in:
Fredrik Nääs 2024-11-01 20:02:29 +01:00
parent 257ce8bca2
commit 6c32b5b070
4 changed files with 230 additions and 4 deletions

View File

@ -87,7 +87,8 @@
"cn": ["cool_test_group"],
"uniqueMember": [
"uid=hamlet,ou=users,dc=zulip,dc=com"
]
],
"description": ["cool_test_group_description"]
},
"cn=another_test_group,ou=groups,dc=zulip,dc=com": {
"objectClass": ["groupOfUniqueNames"],
@ -95,6 +96,7 @@
"uniqueMember": [
"uid=hamlet,ou=users,dc=zulip,dc=com",
"uid=cordelia,ou=users,dc=zulip,dc=com"
]
],
"description": ["another_test_group_description"]
}
}

View File

@ -30,7 +30,8 @@ from django.http import HttpRequest
from django.test import override_settings
from django.urls import reverse
from django.utils.timezone import now as timezone_now
from django_auth_ldap.backend import LDAPSearch, _LDAPUser
from django_auth_ldap.backend import LDAPBackend, LDAPSearch, _LDAPUser
from django_auth_ldap.config import GroupOfUniqueNamesType
from jwt.exceptions import PyJWTError
from onelogin.saml2.auth import OneLogin_Saml2_Auth
from onelogin.saml2.logout_request import OneLogin_Saml2_Logout_Request
@ -6762,6 +6763,109 @@ class TestLDAP(ZulipLDAPTestCase):
self.assertEqual(user_profile.realm.string_id, "zulip")
class TestLDAPGroupDescriptions(ZulipLDAPTestCase):
def delete_groups_from_ldap_directory(self) -> None:
group_ids = [
"cn=cool_test_group,ou=groups,dc=zulip,dc=com",
"cn=another_test_group,ou=groups,dc=zulip,dc=com",
]
for group_id in group_ids:
if group_id in self.mock_ldap.directory:
del self.mock_ldap.directory[group_id]
def delete_group_descriptions_from_ldap_directory(self) -> None:
groups_with_descriptions = [
"cn=cool_test_group,ou=groups,dc=zulip,dc=com",
"cn=another_test_group,ou=groups,dc=zulip,dc=com",
]
for group_id in groups_with_descriptions:
if (
group_id in self.mock_ldap.directory
and "description" in self.mock_ldap.directory[group_id]
):
del self.mock_ldap.directory[group_id]["description"]
@override_settings(AUTHENTICATION_BACKENDS=("zproject.backends.ZulipLDAPAuthBackend",))
def test_get_group_descriptions_when_no_ldap_groups(self) -> None:
self.delete_groups_from_ldap_directory()
realm = get_realm("zulip")
backend = LDAPBackend()
with self.settings(
AUTH_LDAP_GROUP_SEARCH=LDAPSearch(
"ou=groups,dc=zulip,dc=com",
ldap.SCOPE_ONELEVEL,
"(objectClass=groupOfUniqueNames)",
),
AUTH_LDAP_GROUP_TYPE=GroupOfUniqueNamesType(),
LDAP_GROUP_DESCRIPTION_ATTR="description",
):
username = "hamlet"
ldap_user = ZulipLDAPUser(backend, username=username, realm=realm)
self.assertEqual(ldap_user._get_group_descriptions(), {})
@override_settings(AUTHENTICATION_BACKENDS=("zproject.backends.ZulipLDAPAuthBackend",))
def test_get_group_descriptions_when_ldap_groups_exist_with_no_descriptions(self) -> None:
self.delete_group_descriptions_from_ldap_directory()
realm = get_realm("zulip")
backend = LDAPBackend()
with self.settings(
AUTH_LDAP_GROUP_SEARCH=LDAPSearch(
"ou=groups,dc=zulip,dc=com",
ldap.SCOPE_ONELEVEL,
"(objectClass=groupOfUniqueNames)",
),
AUTH_LDAP_GROUP_TYPE=GroupOfUniqueNamesType(),
LDAP_GROUP_DESCRIPTION_ATTR="description",
):
username = "hamlet"
ldap_user = ZulipLDAPUser(backend, username=username, realm=realm)
self.assertEqual(
ldap_user._get_group_descriptions(),
{"cool_test_group": None, "another_test_group": None},
)
@override_settings(AUTHENTICATION_BACKENDS=("zproject.backends.ZulipLDAPAuthBackend",))
def test_get_group_descriptions_setting_not_configured(
self,
) -> None:
realm = get_realm("zulip")
backend = LDAPBackend()
with self.settings(
AUTH_LDAP_GROUP_SEARCH=LDAPSearch(
"ou=groups,dc=zulip,dc=com",
ldap.SCOPE_ONELEVEL,
"(objectClass=groupOfUniqueNames)",
),
AUTH_LDAP_GROUP_TYPE=GroupOfUniqueNamesType(),
):
username = "hamlet"
ldap_user = ZulipLDAPUser(backend, username=username, realm=realm)
self.assertEqual(ldap_user._get_group_descriptions(), {})
@override_settings(AUTHENTICATION_BACKENDS=("zproject.backends.ZulipLDAPAuthBackend",))
def test_get_group_descriptions_when_ldap_groups_exist_with_descriptions(self) -> None:
realm = get_realm("zulip")
backend = LDAPBackend()
with self.settings(
AUTH_LDAP_GROUP_SEARCH=LDAPSearch(
"ou=groups,dc=zulip,dc=com",
ldap.SCOPE_ONELEVEL,
"(objectClass=groupOfUniqueNames)",
),
AUTH_LDAP_GROUP_TYPE=GroupOfUniqueNamesType(),
LDAP_GROUP_DESCRIPTION_ATTR="description",
):
username = "hamlet"
ldap_user = ZulipLDAPUser(backend, username=username, realm=realm)
self.assertEqual(
ldap_user._get_group_descriptions(),
{
"cool_test_group": "cool_test_group_description",
"another_test_group": "another_test_group_description",
},
)
class TestZulipLDAPUserPopulator(ZulipLDAPTestCase):
def test_authenticate(self) -> None:
backend = ZulipLDAPUserPopulator()
@ -7920,6 +8024,62 @@ class LDAPGroupSyncTest(ZulipTestCase):
django_ldap_log.output,
)
# Now we'll test syncing of group descriptions. If a Zulip group has a
# corresponding LDAP group, and the LDAP group has a description, then
# the description of the Zulip group should be updated to match the
# description of the LDAP group.
with (
self.settings(
AUTH_LDAP_GROUP_SEARCH=LDAPSearch(
"ou=groups,dc=zulip,dc=com",
ldap.SCOPE_ONELEVEL,
"(objectClass=groupOfUniqueNames)",
),
LDAP_SYNCHRONIZED_GROUPS_BY_REALM={
"zulip": [
"cool_test_group",
]
},
LDAP_GROUP_DESCRIPTION_ATTR="description",
LDAP_APPEND_DOMAIN="zulip.com",
),
self.assertLogs("zulip.ldap", "DEBUG") as zulip_ldap_log,
):
ldap_group_description = self.mock_ldap.directory[
"cn=cool_test_group,ou=groups,dc=zulip,dc=com"
]["description"][0]
zulip_group_description = NamedUserGroup.objects.get(
realm=realm, name="cool_test_group"
).description
sync_user_from_ldap(hamlet, mock.Mock())
self.assertEqual(
NamedUserGroup.objects.get(realm=realm, name="cool_test_group").description,
ldap_group_description,
)
# If the LDAP group has no description, no changes to the Zulip group should be made.
del self.mock_ldap.directory["cn=cool_test_group,ou=groups,dc=zulip,dc=com"][
"description"
]
sync_user_from_ldap(hamlet, mock.Mock())
self.assertEqual(
NamedUserGroup.objects.get(realm=realm, name="cool_test_group").description,
ldap_group_description,
)
self.assertEqual(
zulip_ldap_log.output,
[
f"DEBUG:zulip.ldap:Syncing groups for user: {hamlet.id}",
"DEBUG:zulip.ldap:intended groups: {'cool_test_group'}; zulip groups: {'cool_test_group'}",
f"DEBUG:zulip.ldap:Updating group description for cool_test_group from {zulip_group_description} to {ldap_group_description}",
f"DEBUG:zulip.ldap:Syncing groups for user: {hamlet.id}",
"DEBUG:zulip.ldap:intended groups: {'cool_test_group'}; zulip groups: {'cool_test_group'}",
],
)
# Don't load the base class as a test: https://bugs.python.org/issue17519.
del SocialAuthBase

View File

@ -35,7 +35,7 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from django.utils.translation import gettext as _
from django_auth_ldap.backend import LDAPBackend, _LDAPUser, ldap_error
from django_auth_ldap.backend import LDAPBackend, _LDAPUser, _LDAPUserGroups, ldap_error
from lxml.etree import XMLSyntaxError
from onelogin.saml2 import compat as onelogin_saml2_compat
from onelogin.saml2.auth import OneLogin_Saml2_Auth
@ -72,6 +72,7 @@ from zerver.actions.custom_profile_fields import do_update_user_custom_profile_d
from zerver.actions.user_groups import (
bulk_add_members_to_user_groups,
bulk_remove_members_from_user_groups,
do_update_user_group_description,
)
from zerver.actions.user_settings import do_regenerate_api_key
from zerver.actions.users import do_change_user_role, do_deactivate_user
@ -957,6 +958,8 @@ class ZulipLDAPAuthBackendBase(ZulipAuthMixin, LDAPBackend):
to the LDAP groups ldap_user belongs to.
(2) Makes sure the user doesn't have membership in the Zulip UserGroups corresponding
to the LDAP groups ldap_user doesn't belong to.
(3) Makes sure the group descriptions of the Zulip UserGroups corresponds to the descriptions
in LDAP.
"""
if user_profile.realm.string_id not in settings.LDAP_SYNCHRONIZED_GROUPS_BY_REALM:
@ -1018,6 +1021,38 @@ class ZulipLDAPAuthBackendBase(ZulipAuthMixin, LDAPBackend):
groups_for_membership_deletion, [user_profile.id], acting_user=None
)
if settings.LDAP_GROUP_DESCRIPTION_ATTR is None:
# Syncing of group descriptions is not configured.
return
ldap_group_descriptions_dict = ldap_user._get_group_descriptions()
zulip_group_descriptions_dict = {
group.name: group.description
for group in NamedUserGroup.objects.filter(
realm=user_profile.realm, name__in=configured_ldap_group_names_for_sync
)
}
for group_name, ldap_description in ldap_group_descriptions_dict.items():
if group_name in intended_group_name_set_for_user:
zulip_description = zulip_group_descriptions_dict.get(group_name)
if ldap_description is not None and zulip_description != ldap_description:
# Sanity assert: make sure we didn't get some strange data from LDAP.
assert isinstance(ldap_description, str)
user_group = NamedUserGroup.objects.get(
name=group_name, realm=user_profile.realm
)
ldap_logger.debug(
"Updating group description for %s from %s to %s",
group_name,
zulip_description,
ldap_description,
)
do_update_user_group_description(
user_group, ldap_description, acting_user=None
)
except Exception as e:
raise ZulipLDAPError(str(e)) from e
@ -1194,6 +1229,33 @@ class ZulipLDAPUser(_LDAPUser):
super().__init__(*args, **kwargs)
def _get_group_descriptions(self) -> dict[str, str | None]:
if not settings.LDAP_GROUP_DESCRIPTION_ATTR:
return {}
ldap_group_descriptions: dict[str, str | None] = {}
self._groups: _LDAPUserGroups = self._get_groups()
group_infos: list[tuple[str, dict[str, list[str]]]] = self._groups._get_group_infos()
for group_info in group_infos:
group_name: str = self._groups._group_type.group_name_from_info(group_info)
if group_name is not None:
group_description = self.group_description_from_info(group_info)
ldap_group_descriptions[group_name] = group_description
return ldap_group_descriptions
def group_description_from_info(
self, group_info: tuple[str, dict[str, list[str]]]
) -> str | None:
assert settings.LDAP_GROUP_DESCRIPTION_ATTR is not None
try:
description_list = group_info[1][settings.LDAP_GROUP_DESCRIPTION_ATTR]
description = description_list[0] if description_list else None
except (KeyError, IndexError):
description = None
# Ensure we're returning the correct type.
assert description is None or isinstance(description, str)
return description
class ZulipLDAPUserPopulator(ZulipLDAPAuthBackendBase):
"""Just like ZulipLDAPAuthBackend, but doesn't let you log in. Used

View File

@ -74,6 +74,8 @@ FAKE_LDAP_NUM_USERS = 8
AUTH_LDAP_ADVANCED_REALM_ACCESS_CONTROL: dict[str, Any] | None = None
LDAP_SYNCHRONIZED_GROUPS_BY_REALM: dict[str, list[str]] = {}
AUTH_LDAP_GROUP_TYPE: LDAPGroupType = GroupOfUniqueNamesType()
LDAP_GROUP_DESCRIPTION_ATTR: str | None = None
AUTH_LDAP_GROUP_SEARCH: Optional["LDAPSearch"] = None
# Social auth; we support providing values for some of these
# settings in zulip-secrets.conf instead of settings.py in development.