users: Send events only to users who can access the modified user.

This commit adds code to make sure that update events for changing
a user's role, email, etc. are not sent to guests who cannot access
the modified user.
This commit is contained in:
Sahil Batra 2023-10-11 13:04:26 +05:30 committed by Tim Abbott
parent 6f14d105a7
commit 650e55fef8
7 changed files with 224 additions and 31 deletions

View File

@ -20,6 +20,11 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 8.0 ## Changes in Zulip 8.0
**Feature level 228**
* [`GET /events`](/api/get-events): `realm_user` events with `op: "update"`
are now only sent to users who can access the modified user.
**Feature level 227** **Feature level 227**
* [`PATCH /realm/user_settings_defaults`](/api/update-realm-user-settings-defaults), * [`PATCH /realm/user_settings_defaults`](/api/update-realm-user-settings-defaults),

View File

@ -8,6 +8,7 @@ from zerver.lib.exceptions import JsonableError
from zerver.lib.external_accounts import DEFAULT_EXTERNAL_ACCOUNTS from zerver.lib.external_accounts import DEFAULT_EXTERNAL_ACCOUNTS
from zerver.lib.streams import render_stream_description from zerver.lib.streams import render_stream_description
from zerver.lib.types import ProfileDataElementUpdateDict, ProfileFieldData from zerver.lib.types import ProfileDataElementUpdateDict, ProfileFieldData
from zerver.lib.users import get_user_ids_who_can_access_user
from zerver.models import ( from zerver.models import (
CustomProfileField, CustomProfileField,
CustomProfileFieldValue, CustomProfileFieldValue,
@ -139,7 +140,7 @@ def notify_user_update_custom_profile_data(
data["rendered_value"] = field["rendered_value"] data["rendered_value"] = field["rendered_value"]
payload = dict(user_id=user_profile.id, custom_profile_field=data) payload = dict(user_id=user_profile.id, custom_profile_field=data)
event = dict(type="realm_user", op="update", person=payload) event = dict(type="realm_user", op="update", person=payload)
send_event(user_profile.realm, event, active_user_ids(user_profile.realm.id)) send_event(user_profile.realm, event, get_user_ids_who_can_access_user(user_profile))
def do_update_user_custom_profile_data_if_changed( def do_update_user_custom_profile_data_if_changed(

View File

@ -25,7 +25,9 @@ from zerver.lib.users import (
can_access_delivery_email, can_access_delivery_email,
check_bot_name_available, check_bot_name_available,
check_full_name, check_full_name,
get_user_ids_who_can_access_user,
get_users_with_access_to_real_email, get_users_with_access_to_real_email,
user_access_restricted_in_realm,
) )
from zerver.lib.utils import generate_api_key from zerver.lib.utils import generate_api_key
from zerver.models import ( from zerver.models import (
@ -36,7 +38,6 @@ from zerver.models import (
ScheduledMessageNotificationEmail, ScheduledMessageNotificationEmail,
UserPresence, UserPresence,
UserProfile, UserProfile,
active_user_ids,
bot_owner_user_ids, bot_owner_user_ids,
get_client, get_client,
get_user_profile_by_id, get_user_profile_by_id,
@ -50,14 +51,27 @@ def send_user_email_update_event(user_profile: UserProfile) -> None:
send_event_on_commit( send_event_on_commit(
user_profile.realm, user_profile.realm,
event, event,
active_user_ids(user_profile.realm_id), get_user_ids_who_can_access_user(user_profile),
) )
def send_delivery_email_update_events( def send_delivery_email_update_events(
user_profile: UserProfile, old_visibility_setting: int, visibility_setting: int user_profile: UserProfile, old_visibility_setting: int, visibility_setting: int
) -> None: ) -> None:
active_users = user_profile.realm.get_active_users() if not user_access_restricted_in_realm(user_profile):
active_users = user_profile.realm.get_active_users()
else:
# The get_user_ids_who_can_access_user returns user IDs and not
# user objects and we instead do one more query for UserProfile
# objects. We need complete UserProfile objects only for a couple
# of cases and it is not worth to query the whole UserProfile
# objects in all the cases and it is fine to do the extra query
# wherever needed.
user_ids_who_can_access_user = get_user_ids_who_can_access_user(user_profile)
active_users = UserProfile.objects.filter(
id__in=user_ids_who_can_access_user, is_active=True
)
delivery_email_now_visible_user_ids = [] delivery_email_now_visible_user_ids = []
delivery_email_now_invisible_user_ids = [] delivery_email_now_invisible_user_ids = []
@ -213,7 +227,7 @@ def do_change_full_name(
send_event( send_event(
user_profile.realm, user_profile.realm,
dict(type="realm_user", op="update", person=payload), dict(type="realm_user", op="update", person=payload),
active_user_ids(user_profile.realm_id), get_user_ids_who_can_access_user(user_profile),
) )
if user_profile.is_bot: if user_profile.is_bot:
send_event( send_event(
@ -343,7 +357,7 @@ def notify_avatar_url_change(user_profile: UserProfile) -> None:
send_event_on_commit( send_event_on_commit(
user_profile.realm, user_profile.realm,
event, event,
active_user_ids(user_profile.realm_id), get_user_ids_who_can_access_user(user_profile),
) )
@ -488,7 +502,7 @@ def do_change_user_setting(
send_event_on_commit( send_event_on_commit(
user_profile.realm, user_profile.realm,
timezone_event, timezone_event,
active_user_ids(user_profile.realm_id), get_user_ids_who_can_access_user(user_profile),
) )
if setting_name == "email_address_visibility": if setting_name == "email_address_visibility":

View File

@ -24,7 +24,7 @@ from zerver.lib.stream_traffic import get_streams_traffic
from zerver.lib.streams import get_streams_for_user, stream_to_dict from zerver.lib.streams import get_streams_for_user, stream_to_dict
from zerver.lib.user_counts import realm_user_count_by_role from zerver.lib.user_counts import realm_user_count_by_role
from zerver.lib.user_groups import get_system_user_group_for_user from zerver.lib.user_groups import get_system_user_group_for_user
from zerver.lib.users import get_active_bots_owned_by_user from zerver.lib.users import get_active_bots_owned_by_user, get_user_ids_who_can_access_user
from zerver.models import ( from zerver.models import (
Message, Message,
Realm, Realm,
@ -35,7 +35,6 @@ from zerver.models import (
Subscription, Subscription,
UserGroupMembership, UserGroupMembership,
UserProfile, UserProfile,
active_user_ids,
bot_owner_user_ids, bot_owner_user_ids,
get_bot_dicts_in_realm, get_bot_dicts_in_realm,
get_bot_services, get_bot_services,
@ -305,7 +304,9 @@ def do_deactivate_user(
person=dict(user_id=user_profile.id, is_active=False), person=dict(user_id=user_profile.id, is_active=False),
) )
send_event_on_commit( send_event_on_commit(
user_profile.realm, event_deactivate_user, active_user_ids(user_profile.realm_id) user_profile.realm,
event_deactivate_user,
get_user_ids_who_can_access_user(user_profile),
) )
if user_profile.is_bot: if user_profile.is_bot:
@ -402,7 +403,7 @@ def do_change_user_role(
event = dict( event = dict(
type="realm_user", op="update", person=dict(user_id=user_profile.id, role=user_profile.role) type="realm_user", op="update", person=dict(user_id=user_profile.id, role=user_profile.role)
) )
send_event_on_commit(user_profile.realm, event, active_user_ids(user_profile.realm_id)) send_event_on_commit(user_profile.realm, event, get_user_ids_who_can_access_user(user_profile))
UserGroupMembership.objects.filter( UserGroupMembership.objects.filter(
user_profile=user_profile, user_group=old_system_group user_profile=user_profile, user_group=old_system_group
@ -450,7 +451,7 @@ def do_change_is_billing_admin(user_profile: UserProfile, value: bool) -> None:
event = dict( event = dict(
type="realm_user", op="update", person=dict(user_id=user_profile.id, is_billing_admin=value) type="realm_user", op="update", person=dict(user_id=user_profile.id, is_billing_admin=value)
) )
send_event(user_profile.realm, event, active_user_ids(user_profile.realm_id)) send_event(user_profile.realm, event, get_user_ids_who_can_access_user(user_profile))
def do_change_can_forge_sender(user_profile: UserProfile, value: bool) -> None: def do_change_can_forge_sender(user_profile: UserProfile, value: bool) -> None:

View File

@ -37,6 +37,8 @@ from zerver.models import (
SystemGroups, SystemGroups,
UserMessage, UserMessage,
UserProfile, UserProfile,
active_non_guest_user_ids,
active_user_ids,
get_fake_email_domain, get_fake_email_domain,
get_realm_user_dicts, get_realm_user_dicts,
get_user, get_user,
@ -587,8 +589,31 @@ def check_can_access_user(
return False return False
def get_user_ids_who_can_access_user(target_user: UserProfile) -> List[int]:
# We assume that caller only needs active users here, since
# this function is used to get users to send events.
realm = target_user.realm
if not user_access_restricted_in_realm(target_user):
return active_user_ids(realm.id)
active_non_guest_user_ids_in_realm = active_non_guest_user_ids(realm.id)
users_in_subscribed_streams_or_huddles_dict = get_subscribers_of_target_user_subscriptions(
[target_user]
)
users_involved_in_dms_dict = get_users_involved_in_dms_with_target_users([target_user], realm)
user_ids_who_can_access_target_user = (
{target_user.id}
| set(active_non_guest_user_ids_in_realm)
| users_in_subscribed_streams_or_huddles_dict[target_user.id]
| users_involved_in_dms_dict[target_user.id]
)
return list(user_ids_who_can_access_target_user)
def get_subscribers_of_target_user_subscriptions( def get_subscribers_of_target_user_subscriptions(
target_users: List[UserProfile], target_users: List[UserProfile], include_deactivated_users_for_huddles: bool = False
) -> Dict[int, Set[int]]: ) -> Dict[int, Set[int]]:
target_user_ids = [user.id for user in target_users] target_user_ids = [user.id for user in target_users]
target_user_subscriptions = ( target_user_subscriptions = (
@ -616,10 +641,15 @@ def get_subscribers_of_target_user_subscriptions(
active=True, active=True,
) )
subs_in_target_user_subscriptions_query = subs_in_target_user_subscriptions_query.filter( if include_deactivated_users_for_huddles:
Q(recipient__type=Recipient.STREAM, is_user_active=True) subs_in_target_user_subscriptions_query = subs_in_target_user_subscriptions_query.filter(
| Q(recipient__type=Recipient.HUDDLE) Q(recipient__type=Recipient.STREAM, is_user_active=True)
) | Q(recipient__type=Recipient.HUDDLE)
)
else:
subs_in_target_user_subscriptions_query = subs_in_target_user_subscriptions_query.filter(
recipient__type__in=[Recipient.STREAM, Recipient.HUDDLE], is_user_active=True
)
subs_in_target_user_subscriptions = subs_in_target_user_subscriptions_query.order_by( subs_in_target_user_subscriptions = subs_in_target_user_subscriptions_query.order_by(
"recipient_id" "recipient_id"
@ -644,7 +674,7 @@ def get_subscribers_of_target_user_subscriptions(
def get_users_involved_in_dms_with_target_users( def get_users_involved_in_dms_with_target_users(
target_users: List[UserProfile], realm: Realm target_users: List[UserProfile], realm: Realm, include_deactivated_users: bool = False
) -> Dict[int, Set[int]]: ) -> Dict[int, Set[int]]:
target_user_ids = [user.id for user in target_users] target_user_ids = [user.id for user in target_users]
@ -657,21 +687,35 @@ def get_users_involved_in_dms_with_target_users(
.values("sender_id", "recipient__type_id") .values("sender_id", "recipient__type_id")
) )
direct_messages_recipient_users_set = {
obj["recipient__type_id"] for obj in direct_messages_recipient_users
}
active_direct_messages_recipient_user_ids = UserProfile.objects.filter(
id__in=list(direct_messages_recipient_users_set), is_active=True
).values_list("id", flat=True)
direct_message_participants_dict: Dict[int, Set[int]] = defaultdict(set) direct_message_participants_dict: Dict[int, Set[int]] = defaultdict(set)
for sender_id, message_rows in itertools.groupby( for sender_id, message_rows in itertools.groupby(
direct_messages_recipient_users, itemgetter("sender_id") direct_messages_recipient_users, itemgetter("sender_id")
): ):
recipient_user_ids = {row["recipient__type_id"] for row in message_rows} recipient_user_ids = {row["recipient__type_id"] for row in message_rows}
if not include_deactivated_users:
recipient_user_ids &= set(active_direct_messages_recipient_user_ids)
direct_message_participants_dict[sender_id] = recipient_user_ids direct_message_participants_dict[sender_id] = recipient_user_ids
personal_recipient_ids_for_target_users = [user.recipient_id for user in target_users] personal_recipient_ids_for_target_users = [user.recipient_id for user in target_users]
direct_message_senders_query = Message.objects.filter(
realm=realm,
recipient_id__in=personal_recipient_ids_for_target_users,
recipient__type=Recipient.PERSONAL,
)
if not include_deactivated_users:
direct_message_senders_query = direct_message_senders_query.filter(sender__is_active=True)
direct_messages_senders = ( direct_messages_senders = (
Message.objects.filter( direct_message_senders_query.order_by("recipient__type_id")
realm=realm,
recipient_id__in=personal_recipient_ids_for_target_users,
recipient__type=Recipient.PERSONAL,
)
.order_by("recipient__type_id")
.distinct("sender_id", "recipient__type_id") .distinct("sender_id", "recipient__type_id")
.values("sender_id", "recipient__type_id") .values("sender_id", "recipient__type_id")
) )
@ -759,11 +803,15 @@ def get_data_for_inaccessible_user(realm: Realm, user_id: int) -> APIUserDict:
return user_dict return user_dict
def get_accessible_user_ids(realm: Realm, user_profile: UserProfile) -> List[int]: def get_accessible_user_ids(
realm: Realm, user_profile: UserProfile, include_deactivated_users: bool = False
) -> List[int]:
subscribers_dict_of_target_user_subscriptions = get_subscribers_of_target_user_subscriptions( subscribers_dict_of_target_user_subscriptions = get_subscribers_of_target_user_subscriptions(
[user_profile] [user_profile], include_deactivated_users_for_huddles=include_deactivated_users
)
users_involved_in_dms_dict = get_users_involved_in_dms_with_target_users(
[user_profile], realm, include_deactivated_users=include_deactivated_users
) )
users_involved_in_dms_dict = get_users_involved_in_dms_with_target_users([user_profile], realm)
# This does not include bots, because either the caller # This does not include bots, because either the caller
# wants only human users or it handles bots separately. # wants only human users or it handles bots separately.
@ -787,7 +835,9 @@ def get_user_dicts_in_realm(
return (all_user_dicts, []) return (all_user_dicts, [])
assert user_profile is not None assert user_profile is not None
accessible_user_ids = get_accessible_user_ids(realm, user_profile) accessible_user_ids = get_accessible_user_ids(
realm, user_profile, include_deactivated_users=True
)
accessible_user_dicts: List[RawUserDict] = [] accessible_user_dicts: List[RawUserDict] = []
inaccessible_user_dicts: List[APIUserDict] = [] inaccessible_user_dicts: List[APIUserDict] = []
@ -896,7 +946,20 @@ def is_2fa_verified(user: UserProfile) -> bool:
def get_users_with_access_to_real_email(user_profile: UserProfile) -> List[int]: def get_users_with_access_to_real_email(user_profile: UserProfile) -> List[int]:
active_users = user_profile.realm.get_active_users() if not user_access_restricted_in_realm(user_profile):
active_users = user_profile.realm.get_active_users()
else:
# The get_user_ids_who_can_access_user returns user IDs and not
# user objects and we instead do one more query for UserProfile
# objects. We need complete UserProfile objects only for a couple
# of cases and it is not worth to query the whole UserProfile
# objects in all the cases and it is fine to do the extra query
# wherever needed.
user_ids_who_can_access_user = get_user_ids_who_can_access_user(user_profile)
active_users = UserProfile.objects.filter(
id__in=user_ids_who_can_access_user, is_active=True
)
return [ return [
user.id user.id
for user in active_users for user in active_users

View File

@ -379,8 +379,11 @@ paths:
} }
- type: object - type: object
description: | description: |
Event sent generally to all users in an organization for changes Event sent generally to all users who can access the modified
in the set of users or those users metadata. user for changes in the set of users or those users metadata.
**Changes**: Prior to Zulip 8.0 (feature level 228), this event
was sent to all users in the organization.
properties: properties:
id: id:
$ref: "#/components/schemas/EventIdSchema" $ref: "#/components/schemas/EventIdSchema"

View File

@ -1356,6 +1356,27 @@ class NormalActionsTest(BaseAction):
check_realm_user_update("events[0]", events[0], "custom_profile_field") check_realm_user_update("events[0]", events[0], "custom_profile_field")
self.assertEqual(events[0]["person"]["custom_profile_field"].keys(), {"id", "value"}) self.assertEqual(events[0]["person"]["custom_profile_field"].keys(), {"id", "value"})
# Test event for updating custom profile data for guests.
self.set_up_db_for_testing_user_access()
self.user_profile = self.example_user("polonius")
field = {
"id": field_id,
"value": "New value",
}
cordelia = self.example_user("cordelia")
events = self.verify_action(
lambda: do_update_user_custom_profile_data_if_changed(cordelia, [field]),
num_events=0,
state_change_expected=False,
)
hamlet = self.example_user("hamlet")
events = self.verify_action(
lambda: do_update_user_custom_profile_data_if_changed(hamlet, [field])
)
check_realm_user_update("events[0]", events[0], "custom_profile_field")
self.assertEqual(events[0]["person"]["custom_profile_field"].keys(), {"id", "value"})
def test_presence_events(self) -> None: def test_presence_events(self) -> None:
events = self.verify_action( events = self.verify_action(
lambda: do_update_user_presence( lambda: do_update_user_presence(
@ -1870,12 +1891,32 @@ class NormalActionsTest(BaseAction):
self.assertEqual(events[0]["person"]["avatar_url"], None) self.assertEqual(events[0]["person"]["avatar_url"], None)
self.assertEqual(events[0]["person"]["avatar_url_medium"], None) self.assertEqual(events[0]["person"]["avatar_url_medium"], None)
self.set_up_db_for_testing_user_access()
self.user_profile = self.example_user("polonius")
cordelia = self.example_user("cordelia")
events = self.verify_action(
lambda: do_change_avatar_fields(
cordelia, UserProfile.AVATAR_FROM_GRAVATAR, acting_user=cordelia
),
num_events=0,
state_change_expected=False,
)
def test_change_full_name(self) -> None: def test_change_full_name(self) -> None:
events = self.verify_action( events = self.verify_action(
lambda: do_change_full_name(self.user_profile, "Sir Hamlet", self.user_profile) lambda: do_change_full_name(self.user_profile, "Sir Hamlet", self.user_profile)
) )
check_realm_user_update("events[0]", events[0], "full_name") check_realm_user_update("events[0]", events[0], "full_name")
self.set_up_db_for_testing_user_access()
cordelia = self.example_user("cordelia")
self.user_profile = self.example_user("polonius")
self.verify_action(
lambda: do_change_full_name(cordelia, "Cordelia", self.user_profile),
num_events=0,
state_change_expected=False,
)
def test_change_user_delivery_email_email_address_visibility_admins(self) -> None: def test_change_user_delivery_email_email_address_visibility_admins(self) -> None:
do_change_user_setting( do_change_user_setting(
self.user_profile, self.user_profile,
@ -1915,6 +1956,25 @@ class NormalActionsTest(BaseAction):
assert isinstance(events[1]["person"]["avatar_url"], str) assert isinstance(events[1]["person"]["avatar_url"], str)
assert isinstance(events[1]["person"]["avatar_url_medium"], str) assert isinstance(events[1]["person"]["avatar_url_medium"], str)
# Reset hamlet's email to original email.
do_change_user_delivery_email(self.user_profile, "hamlet@zulip.com")
self.set_up_db_for_testing_user_access()
cordelia = self.example_user("cordelia")
do_change_user_setting(
cordelia,
"email_address_visibility",
UserProfile.EMAIL_ADDRESS_VISIBILITY_EVERYONE,
acting_user=None,
)
self.user_profile = self.example_user("polonius")
action = lambda: do_change_user_delivery_email(cordelia, "newcordelia@zulip.com")
self.verify_action(
action,
num_events=0,
state_change_expected=False,
)
def test_change_realm_authentication_methods(self) -> None: def test_change_realm_authentication_methods(self) -> None:
def fake_backends() -> Any: def fake_backends() -> Any:
backends = ( backends = (
@ -2253,6 +2313,37 @@ class NormalActionsTest(BaseAction):
check_subscription_peer_add("events[5]", events[5]) check_subscription_peer_add("events[5]", events[5])
check_subscription_peer_add("events[6]", events[6]) check_subscription_peer_add("events[6]", events[6])
def test_change_user_role_for_restricted_users(self) -> None:
self.set_up_db_for_testing_user_access()
self.user_profile = self.example_user("polonius")
for role in [
UserProfile.ROLE_REALM_OWNER,
UserProfile.ROLE_REALM_ADMINISTRATOR,
UserProfile.ROLE_MODERATOR,
UserProfile.ROLE_MEMBER,
UserProfile.ROLE_GUEST,
]:
cordelia = self.example_user("cordelia")
old_role = cordelia.role
num_events = 2
if UserProfile.ROLE_MEMBER in [old_role, role]:
num_events = 3
events = self.verify_action(
partial(do_change_user_role, cordelia, role, acting_user=None),
num_events=num_events,
)
check_user_group_remove_members("events[0]", events[0])
check_user_group_add_members("events[1]", events[1])
if old_role == UserProfile.ROLE_MEMBER:
check_user_group_remove_members("events[2]", events[2])
elif role == UserProfile.ROLE_MEMBER:
check_user_group_add_members("events[2]", events[2])
def test_change_notification_settings(self) -> None: def test_change_notification_settings(self) -> None:
for notification_setting in self.user_profile.notification_setting_types: for notification_setting in self.user_profile.notification_setting_types:
if notification_setting in [ if notification_setting in [
@ -2774,6 +2865,21 @@ class NormalActionsTest(BaseAction):
events = self.verify_action(action, num_events=1) events = self.verify_action(action, num_events=1)
check_realm_user_update("events[0]", events[0], "is_active") check_realm_user_update("events[0]", events[0], "is_active")
do_reactivate_user(user_profile, acting_user=None)
self.set_up_db_for_testing_user_access()
# Test that guest users receive event only
# if they can access the deactivated user.
user_profile = self.example_user("cordelia")
self.user_profile = self.example_user("polonius")
action = lambda: do_deactivate_user(user_profile, acting_user=None)
events = self.verify_action(action, num_events=0, state_change_expected=False)
user_profile = self.example_user("shiva")
action = lambda: do_deactivate_user(user_profile, acting_user=None)
events = self.verify_action(action, num_events=1)
check_realm_user_update("events[0]", events[0], "is_active")
def test_do_reactivate_user(self) -> None: def test_do_reactivate_user(self) -> None:
bot = self.create_bot("test") bot = self.create_bot("test")
self.subscribe(bot, "Denmark") self.subscribe(bot, "Denmark")