mirror of
https://github.com/zulip/zulip.git
synced 2025-11-07 07:23:22 +00:00
ldap: Fix the syncing of user role via AUTH_LDAP_USER_FLAGS_BY_GROUP.
This was broken, due the mechanism simply using our is_guest/is_realm_admin/etc. role setters, but failing to adjust system group memberships - resulting in corrupted database state. We need to ensure that change_user_role is called for setting user role. There are two relevant codepaths that run the sync based on AUTH_LDAP_USER_FLAGS_BY_GROUP and thus need to get this right: 1. manage.py sync_ldap_user_data 2. Just-in-time user creation when a user without a Zulip account logs in for the first using their ldap credentials. After get_or_build_user returns, django-auth-ldap sees that the user account has just been created, and proceeds to run ._populate_user(). Now that both user.save() and do_change_user_realm will be getting called together, we need to ensure this always happens atomically. This imposes the need to override _get_or_create_user to put it in a transaction. The troublesome consequence is that this new `atomic(savepoint=False)` causes the usual type of issue, where tests testing error get their transaction rolled back and cannot continue executing. To get around that, we add a test helper `artificial_transaction_savepoint` which allows these tests to wrap their problematic blocks in an artificial transaction which provides a savepoint, thus preventing the full test transaction rollback derailing the rest of the test.
This commit is contained in:
committed by
Tim Abbott
parent
03ebeb10ab
commit
6ea67a7df2
@@ -499,7 +499,7 @@ def notify_created_bot(user_profile: UserProfile) -> None:
|
|||||||
send_event_on_commit(user_profile.realm, event, bot_owner_user_ids(user_profile))
|
send_event_on_commit(user_profile.realm, event, bot_owner_user_ids(user_profile))
|
||||||
|
|
||||||
|
|
||||||
@transaction.atomic(durable=True)
|
@transaction.atomic(savepoint=False)
|
||||||
def do_create_user(
|
def do_create_user(
|
||||||
email: str,
|
email: str,
|
||||||
password: str | None,
|
password: str | None,
|
||||||
|
|||||||
@@ -1485,10 +1485,7 @@ Output:
|
|||||||
"invite_only": orjson.dumps(invite_only).decode(),
|
"invite_only": orjson.dumps(invite_only).decode(),
|
||||||
}
|
}
|
||||||
post_data.update(extra_post_data)
|
post_data.update(extra_post_data)
|
||||||
# We wrap the API call with a 'transaction.atomic' context
|
with self.artificial_transaction_savepoint():
|
||||||
# manager as it helps us with NOT rolling back the entire
|
|
||||||
# test transaction due to error responses.
|
|
||||||
with transaction.atomic(savepoint=True):
|
|
||||||
result = self.api_post(
|
result = self.api_post(
|
||||||
user,
|
user,
|
||||||
"/api/v1/users/me/subscriptions",
|
"/api/v1/users/me/subscriptions",
|
||||||
@@ -1965,6 +1962,16 @@ Output:
|
|||||||
file_path = upload_message_attachment_from_request(UploadedFile(fp), user)[0]
|
file_path = upload_message_attachment_from_request(UploadedFile(fp), user)[0]
|
||||||
return file_path
|
return file_path
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def artificial_transaction_savepoint(self) -> Iterator[None]:
|
||||||
|
# Sometimes we need to wrap some test code, such as an API call with a
|
||||||
|
# 'transaction.atomic' context manager as it helps us with NOT rolling
|
||||||
|
# back the entire test transaction due to errors expected by the test.
|
||||||
|
# Otherwise, those errors can prevent the test from continuing, and throw
|
||||||
|
# TransactionManagementError instead.
|
||||||
|
with transaction.atomic(savepoint=True):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
class ZulipTestCase(ZulipTestCaseMixin, TestCase):
|
class ZulipTestCase(ZulipTestCaseMixin, TestCase):
|
||||||
@contextmanager
|
@contextmanager
|
||||||
|
|||||||
@@ -82,7 +82,11 @@ from zerver.lib.test_helpers import (
|
|||||||
)
|
)
|
||||||
from zerver.lib.thumbnail import DEFAULT_AVATAR_SIZE, MEDIUM_AVATAR_SIZE, resize_avatar
|
from zerver.lib.thumbnail import DEFAULT_AVATAR_SIZE, MEDIUM_AVATAR_SIZE, resize_avatar
|
||||||
from zerver.lib.types import Validator
|
from zerver.lib.types import Validator
|
||||||
from zerver.lib.user_groups import is_user_in_group
|
from zerver.lib.user_groups import (
|
||||||
|
get_system_user_group_by_name,
|
||||||
|
get_system_user_group_for_user,
|
||||||
|
is_user_in_group,
|
||||||
|
)
|
||||||
from zerver.lib.users import get_users_for_api
|
from zerver.lib.users import get_users_for_api
|
||||||
from zerver.lib.utils import assert_is_not_none
|
from zerver.lib.utils import assert_is_not_none
|
||||||
from zerver.lib.validator import (
|
from zerver.lib.validator import (
|
||||||
@@ -105,6 +109,7 @@ from zerver.models import (
|
|||||||
Stream,
|
Stream,
|
||||||
UserProfile,
|
UserProfile,
|
||||||
)
|
)
|
||||||
|
from zerver.models.groups import SystemGroups, UserGroupMembership
|
||||||
from zerver.models.realms import clear_supported_auth_backends_cache, get_realm
|
from zerver.models.realms import clear_supported_auth_backends_cache, get_realm
|
||||||
from zerver.models.users import PasswordTooWeakError, get_user_by_delivery_email
|
from zerver.models.users import PasswordTooWeakError, get_user_by_delivery_email
|
||||||
from zerver.signals import JUST_CREATED_THRESHOLD
|
from zerver.signals import JUST_CREATED_THRESHOLD
|
||||||
@@ -452,6 +457,13 @@ class AuthBackendTest(ZulipTestCase):
|
|||||||
|
|
||||||
username = self.get_email()
|
username = self.get_email()
|
||||||
backend = ZulipLDAPAuthBackend()
|
backend = ZulipLDAPAuthBackend()
|
||||||
|
orig_authenticate = backend.authenticate
|
||||||
|
|
||||||
|
def wrapped_authenticate(*args: Any, **kwargs: Any) -> UserProfile | None:
|
||||||
|
with self.artificial_transaction_savepoint():
|
||||||
|
return orig_authenticate(*args, **kwargs)
|
||||||
|
|
||||||
|
backend.authenticate = wrapped_authenticate
|
||||||
|
|
||||||
# Test LDAP auth fails when LDAP server rejects password
|
# Test LDAP auth fails when LDAP server rejects password
|
||||||
self.assertIsNone(
|
self.assertIsNone(
|
||||||
@@ -5090,6 +5102,7 @@ class FetchAPIKeyTest(ZulipTestCase):
|
|||||||
# We do test two combinations here:
|
# We do test two combinations here:
|
||||||
# The first user has no (department) attribute set
|
# The first user has no (department) attribute set
|
||||||
# The second user has one set, but to a different value
|
# The second user has one set, but to a different value
|
||||||
|
with self.artificial_transaction_savepoint():
|
||||||
result = self.client_post(
|
result = self.client_post(
|
||||||
"/api/v1/fetch_api_key",
|
"/api/v1/fetch_api_key",
|
||||||
dict(username="hamlet", password=self.ldap_password("hamlet")),
|
dict(username="hamlet", password=self.ldap_password("hamlet")),
|
||||||
@@ -5097,6 +5110,7 @@ class FetchAPIKeyTest(ZulipTestCase):
|
|||||||
self.assert_json_error(result, "Your username or password is incorrect", 401)
|
self.assert_json_error(result, "Your username or password is incorrect", 401)
|
||||||
|
|
||||||
self.change_ldap_user_attr("hamlet", "department", "testWrongRealm")
|
self.change_ldap_user_attr("hamlet", "department", "testWrongRealm")
|
||||||
|
with self.artificial_transaction_savepoint():
|
||||||
result = self.client_post(
|
result = self.client_post(
|
||||||
"/api/v1/fetch_api_key",
|
"/api/v1/fetch_api_key",
|
||||||
dict(username="hamlet", password=self.ldap_password("hamlet")),
|
dict(username="hamlet", password=self.ldap_password("hamlet")),
|
||||||
@@ -5124,6 +5138,7 @@ class FetchAPIKeyTest(ZulipTestCase):
|
|||||||
self.init_default_ldap_database()
|
self.init_default_ldap_database()
|
||||||
|
|
||||||
# The first user has no attribute set
|
# The first user has no attribute set
|
||||||
|
with self.artificial_transaction_savepoint():
|
||||||
result = self.client_post(
|
result = self.client_post(
|
||||||
"/api/v1/fetch_api_key",
|
"/api/v1/fetch_api_key",
|
||||||
dict(username="hamlet", password=self.ldap_password("hamlet")),
|
dict(username="hamlet", password=self.ldap_password("hamlet")),
|
||||||
@@ -5132,6 +5147,7 @@ class FetchAPIKeyTest(ZulipTestCase):
|
|||||||
|
|
||||||
self.change_ldap_user_attr("hamlet", "test2", "testing")
|
self.change_ldap_user_attr("hamlet", "test2", "testing")
|
||||||
# Check with only one set
|
# Check with only one set
|
||||||
|
with self.artificial_transaction_savepoint():
|
||||||
result = self.client_post(
|
result = self.client_post(
|
||||||
"/api/v1/fetch_api_key",
|
"/api/v1/fetch_api_key",
|
||||||
dict(username="hamlet", password=self.ldap_password("hamlet")),
|
dict(username="hamlet", password=self.ldap_password("hamlet")),
|
||||||
@@ -5167,6 +5183,7 @@ class FetchAPIKeyTest(ZulipTestCase):
|
|||||||
|
|
||||||
# Setting test1 to wrong value
|
# Setting test1 to wrong value
|
||||||
self.change_ldap_user_attr("hamlet", "test1", "invalid")
|
self.change_ldap_user_attr("hamlet", "test1", "invalid")
|
||||||
|
with self.artificial_transaction_savepoint():
|
||||||
result = self.client_post(
|
result = self.client_post(
|
||||||
"/api/v1/fetch_api_key",
|
"/api/v1/fetch_api_key",
|
||||||
dict(username="hamlet", password=self.ldap_password("hamlet")),
|
dict(username="hamlet", password=self.ldap_password("hamlet")),
|
||||||
@@ -6500,6 +6517,21 @@ class TestLDAP(ZulipLDAPTestCase):
|
|||||||
)
|
)
|
||||||
self.assertIs(user, None)
|
self.assertIs(user, None)
|
||||||
|
|
||||||
|
with (
|
||||||
|
self.settings(LDAP_APPEND_DOMAIN="zulip.com"),
|
||||||
|
self.assertLogs("zulip.auth.ldap", level="DEBUG") as log_debug,
|
||||||
|
):
|
||||||
|
user = self.backend.authenticate(
|
||||||
|
request=mock.MagicMock(),
|
||||||
|
username=self.example_email("hamlet"),
|
||||||
|
password="",
|
||||||
|
realm=get_realm("zulip"),
|
||||||
|
)
|
||||||
|
self.assertIs(user, None)
|
||||||
|
self.assertEqual(
|
||||||
|
log_debug.output[0], "DEBUG:zulip.auth.ldap:Rejecting empty password for hamlet"
|
||||||
|
)
|
||||||
|
|
||||||
@override_settings(AUTHENTICATION_BACKENDS=("zproject.backends.ZulipLDAPAuthBackend",))
|
@override_settings(AUTHENTICATION_BACKENDS=("zproject.backends.ZulipLDAPAuthBackend",))
|
||||||
def test_login_failure_due_to_nonexistent_user(self) -> None:
|
def test_login_failure_due_to_nonexistent_user(self) -> None:
|
||||||
with (
|
with (
|
||||||
@@ -6972,6 +7004,7 @@ class TestZulipLDAPUserPopulator(ZulipLDAPTestCase):
|
|||||||
),
|
),
|
||||||
self.assertLogs("django_auth_ldap") as ldap_logs,
|
self.assertLogs("django_auth_ldap") as ldap_logs,
|
||||||
self.assertRaises(AssertionError),
|
self.assertRaises(AssertionError),
|
||||||
|
self.artificial_transaction_savepoint(),
|
||||||
):
|
):
|
||||||
self.perform_ldap_sync(self.example_user("hamlet"))
|
self.perform_ldap_sync(self.example_user("hamlet"))
|
||||||
hamlet.refresh_from_db()
|
hamlet.refresh_from_db()
|
||||||
@@ -7818,6 +7851,68 @@ class JWTFetchAPIKeyTest(ZulipTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class LDAPGroupSyncTest(ZulipTestCase):
|
class LDAPGroupSyncTest(ZulipTestCase):
|
||||||
|
@override_settings(AUTHENTICATION_BACKENDS=("zproject.backends.ZulipLDAPAuthBackend",))
|
||||||
|
def test_ldap_sync_role_from_groups(self) -> None:
|
||||||
|
self.init_default_ldap_database()
|
||||||
|
|
||||||
|
realm = get_realm("zulip")
|
||||||
|
hamlet = self.example_user("hamlet")
|
||||||
|
with (
|
||||||
|
self.settings(
|
||||||
|
LDAP_APPEND_DOMAIN="zulip.com",
|
||||||
|
AUTH_LDAP_GROUP_SEARCH=LDAPSearch(
|
||||||
|
"ou=groups,dc=zulip,dc=com",
|
||||||
|
ldap.SCOPE_ONELEVEL,
|
||||||
|
"(objectClass=groupOfUniqueNames)",
|
||||||
|
),
|
||||||
|
AUTH_LDAP_USER_FLAGS_BY_GROUP={
|
||||||
|
"is_realm_admin": "cn=cool_test_group,ou=groups,dc=zulip,dc=com",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
sync_user_from_ldap(hamlet, mock.Mock())
|
||||||
|
hamlet.refresh_from_db()
|
||||||
|
self.assertEqual(hamlet.role, UserProfile.ROLE_REALM_ADMINISTRATOR)
|
||||||
|
|
||||||
|
admin_group = get_system_user_group_by_name(SystemGroups.ADMINISTRATORS, realm.id)
|
||||||
|
self.assertEqual(get_system_user_group_for_user(hamlet), admin_group)
|
||||||
|
|
||||||
|
# Verify UserGroupMembership is set up correct - the user's direct membership should be the admins group.
|
||||||
|
self.assertIn(
|
||||||
|
admin_group.id,
|
||||||
|
set(
|
||||||
|
UserGroupMembership.objects.filter(user_profile=hamlet).values_list(
|
||||||
|
"user_group_id", flat=True
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Now test the just-in-time user creation codepath.
|
||||||
|
# A user with no Zulip account logs in for the first time with their LDAP credentials.
|
||||||
|
# The account is created on the fly and should the .role and system groups memberships
|
||||||
|
# set correctly from the start.
|
||||||
|
self.mock_ldap.directory["cn=cool_test_group,ou=groups,dc=zulip,dc=com"][
|
||||||
|
"uniqueMember"
|
||||||
|
] = ["uid=newuser,ou=users,dc=zulip,dc=com"]
|
||||||
|
password = self.ldap_password("newuser")
|
||||||
|
email = "newuser@zulip.com"
|
||||||
|
self.login_with_return(email, password)
|
||||||
|
user_profile = UserProfile.objects.get(delivery_email=email)
|
||||||
|
|
||||||
|
self.assertEqual(user_profile.role, UserProfile.ROLE_REALM_ADMINISTRATOR)
|
||||||
|
|
||||||
|
admin_group = get_system_user_group_by_name(SystemGroups.ADMINISTRATORS, realm.id)
|
||||||
|
self.assertEqual(get_system_user_group_for_user(user_profile), admin_group)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
admin_group.id,
|
||||||
|
set(
|
||||||
|
UserGroupMembership.objects.filter(user_profile=user_profile).values_list(
|
||||||
|
"user_group_id", flat=True
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@override_settings(AUTHENTICATION_BACKENDS=("zproject.backends.ZulipLDAPAuthBackend",))
|
@override_settings(AUTHENTICATION_BACKENDS=("zproject.backends.ZulipLDAPAuthBackend",))
|
||||||
def test_ldap_group_sync(self) -> None:
|
def test_ldap_group_sync(self) -> None:
|
||||||
self.init_default_ldap_database()
|
self.init_default_ldap_database()
|
||||||
|
|||||||
@@ -3627,6 +3627,7 @@ class UserSignUpTest(ZulipTestCase):
|
|||||||
self.assertLogs("zulip.auth.ldap", "WARNING") as mock_log,
|
self.assertLogs("zulip.auth.ldap", "WARNING") as mock_log,
|
||||||
):
|
):
|
||||||
original_user_count = UserProfile.objects.count()
|
original_user_count = UserProfile.objects.count()
|
||||||
|
with self.artificial_transaction_savepoint():
|
||||||
self.login_with_return(username, password, HTTP_HOST=subdomain + ".testserver")
|
self.login_with_return(username, password, HTTP_HOST=subdomain + ".testserver")
|
||||||
# Verify that the process failed as intended - no UserProfile is created.
|
# Verify that the process failed as intended - no UserProfile is created.
|
||||||
self.assertEqual(UserProfile.objects.count(), original_user_count)
|
self.assertEqual(UserProfile.objects.count(), original_user_count)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from django.contrib.auth.backends import RemoteUserBackend
|
|||||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import validate_email
|
from django.core.validators import validate_email
|
||||||
|
from django.db import transaction
|
||||||
from django.dispatch import Signal, receiver
|
from django.dispatch import Signal, receiver
|
||||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
@@ -1208,6 +1209,38 @@ class ZulipLDAPUser(_LDAPUser):
|
|||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
@transaction.atomic(savepoint=False)
|
||||||
|
def _get_or_create_user(self, force_populate: bool = False) -> UserProfile:
|
||||||
|
# This function is responsible for the core logic of syncing
|
||||||
|
# a user's data with ldap - run in both populate_user codepath
|
||||||
|
# and just-in-time user creation upon first login via LDAP.
|
||||||
|
#
|
||||||
|
# To ensure we don't end up with corrupted database state,
|
||||||
|
# we need to run these operations atomically.
|
||||||
|
return super()._get_or_create_user(force_populate=force_populate)
|
||||||
|
|
||||||
|
def _populate_user(self) -> None:
|
||||||
|
"""
|
||||||
|
Populates our User object with information from the LDAP directory.
|
||||||
|
"""
|
||||||
|
assert isinstance(self._user, UserProfile)
|
||||||
|
user_profile = self._user
|
||||||
|
original_role = user_profile.role
|
||||||
|
|
||||||
|
# _populate_user() will make whatever changes to the user's attributes
|
||||||
|
# that it needs - possibly changing the user's role multiple times e.g.
|
||||||
|
# as it cycles through various role setters in AUTH_LDAP_USER_FLAGS_BY_GROUP.
|
||||||
|
#
|
||||||
|
# For that reason, we want to only look at the final role value after
|
||||||
|
# it is executed. This is the actual change (if any) that should take place.
|
||||||
|
# This allows us to call do_change_user_role only once.
|
||||||
|
super()._populate_user()
|
||||||
|
if user_profile.role != original_role:
|
||||||
|
# Change the role properly, updating system groups.
|
||||||
|
updated_role = user_profile.role
|
||||||
|
user_profile.role = original_role
|
||||||
|
do_change_user_role(user_profile, updated_role, acting_user=None)
|
||||||
|
|
||||||
def _get_groups(self) -> _LDAPUserGroups:
|
def _get_groups(self) -> _LDAPUserGroups:
|
||||||
groups = super()._get_groups()
|
groups = super()._get_groups()
|
||||||
if settings.DEVELOPMENT:
|
if settings.DEVELOPMENT:
|
||||||
|
|||||||
Reference in New Issue
Block a user