stream: Modify flag to allow access for users with metadata access.

This commit is contained in:
Shubham Padia
2025-02-06 17:33:25 +00:00
committed by Tim Abbott
parent 9725de99e9
commit 35f9305acb
7 changed files with 265 additions and 57 deletions

View File

@@ -186,7 +186,7 @@ def get_chart_data_for_stream(
user_profile,
stream_id,
require_active=True,
allow_realm_admin=True,
require_content_access=False,
)
return do_get_chart_data(

View File

@@ -915,7 +915,7 @@ def send_subscription_remove_events(
stream
for stream in streams_by_user[user_profile.id]
if not check_basic_stream_access(
user_profile, stream, is_subscribed=False, allow_realm_admin=True
user_profile, stream, is_subscribed=False, require_content_access=False
)
]

View File

@@ -466,9 +466,15 @@ def check_for_exactly_one_stream_arg(stream_id: int | None, stream: str | None)
raise IncompatibleParametersError(["stream_id", "stream"])
@dataclass
class UserGroupMembershipDetails:
user_recursive_group_ids: set[int] | None
def user_has_content_access(
user_profile: UserProfile,
stream: Stream,
user_group_membership_details: UserGroupMembershipDetails,
*,
is_subscribed: bool,
) -> bool:
@@ -478,14 +484,17 @@ def user_has_content_access(
if is_subscribed:
return True
if not stream.invite_only and not user_profile.is_guest:
if stream.is_public() and not user_profile.is_guest:
return True
user_recursive_group_ids = set(
get_recursive_membership_groups(user_profile).values_list("id", flat=True)
)
if user_group_membership_details.user_recursive_group_ids is None:
user_group_membership_details.user_recursive_group_ids = set(
get_recursive_membership_groups(user_profile).values_list("id", flat=True)
)
if is_user_in_can_add_subscribers_group(stream, user_recursive_group_ids):
if is_user_in_can_add_subscribers_group(
stream, user_group_membership_details.user_recursive_group_ids
):
return True
return False
@@ -514,7 +523,10 @@ def check_stream_access_for_delete_or_update_requiring_metadata_access(
# to the channel for this block, but since we have ruled out
# the possibility that the user is a channel admin, checking
# for content access will save us valuable DB queries.
if user_has_content_access(user_profile, stream, is_subscribed=sub is not None):
user_group_membership_details = UserGroupMembershipDetails(user_recursive_group_ids=None)
if user_has_content_access(
user_profile, stream, user_group_membership_details, is_subscribed=sub is not None
):
raise CannotAdministerChannelError
raise JsonableError(error)
@@ -555,38 +567,49 @@ def check_basic_stream_access(
stream: Stream,
*,
is_subscribed: bool,
allow_realm_admin: bool = False,
require_content_access: bool = True,
) -> bool:
# Any realm user, even guests, can access web_public streams.
if stream.is_web_public:
user_group_membership_details = UserGroupMembershipDetails(user_recursive_group_ids=None)
if user_has_content_access(
user_profile, stream, user_group_membership_details, is_subscribed=is_subscribed
):
return True
# If the stream is in your realm and public, you can access it.
if stream.is_public() and not user_profile.is_guest:
return True
if not require_content_access:
if user_profile.is_realm_admin:
return True
# Or if you are subscribed to the stream, you can access it.
if is_subscribed:
return True
# For some specific callers (e.g. getting list of subscribers,
# removing other users from a stream, and updating stream name and
# description), we allow realm admins to access stream even if
# they are not subscribed to a private stream.
if user_profile.is_realm_admin and allow_realm_admin:
return True
# This will not get fired in practice since we will only arrive
# at this if block if `user_has_content_access` returns False.
# In every case that `user_has_content_access` returns False,
# we already would have calculated `user_recursive_group_ids`.
# It is still good to keep this around in case there are
# changes in that function.
if user_group_membership_details.user_recursive_group_ids is None: # nocoverage
user_group_membership_details.user_recursive_group_ids = set( # nocoverage
get_recursive_membership_groups(user_profile).values_list(
"id", flat=True
) # nocoverage
) # nocoverage
if has_metadata_access_to_channel_via_groups(
user_group_membership_details.user_recursive_group_ids,
stream.can_administer_channel_group_id,
stream.can_add_subscribers_group_id,
):
return True
return False
# Only set allow_realm_admin flag to True when you want to allow realm admin to
# access unsubscribed private stream content.
# Only set require_content_access flag to False when you want
# to allow users with metadata access to access unsubscribed private
# stream content.
def access_stream_common(
user_profile: UserProfile,
stream: Stream,
error: str,
require_active: bool = True,
allow_realm_admin: bool = False,
require_content_access: bool = True,
) -> Subscription | None:
"""Common function for backend code where the target use attempts to
access the target stream, returning all the data fetched along the
@@ -608,7 +631,10 @@ def access_stream_common(
sub = None
if not stream.deactivated and check_basic_stream_access(
user_profile, stream, is_subscribed=sub is not None, allow_realm_admin=allow_realm_admin
user_profile,
stream,
is_subscribed=sub is not None,
require_content_access=require_content_access,
):
return sub
@@ -621,7 +647,7 @@ def access_stream_by_id(
user_profile: UserProfile,
stream_id: int,
require_active: bool = True,
allow_realm_admin: bool = False,
require_content_access: bool = True,
) -> tuple[Stream, Subscription | None]:
error = _("Invalid channel ID")
try:
@@ -634,7 +660,7 @@ def access_stream_by_id(
stream,
error,
require_active=require_active,
allow_realm_admin=allow_realm_admin,
require_content_access=require_content_access,
)
return (stream, sub)
@@ -643,7 +669,7 @@ def access_stream_by_id_for_message(
user_profile: UserProfile,
stream_id: int,
require_active: bool = True,
allow_realm_admin: bool = False,
require_content_access: bool = True,
) -> tuple[Stream, Subscription | None]:
"""
Variant of access_stream_by_id that uses get_stream_by_id_for_sending_message
@@ -660,7 +686,7 @@ def access_stream_by_id_for_message(
stream,
error,
require_active=require_active,
allow_realm_admin=allow_realm_admin,
require_content_access=require_content_access,
)
return (stream, sub)
@@ -697,7 +723,7 @@ def check_stream_name_available(realm: Realm, name: str) -> None:
def access_stream_by_name(
user_profile: UserProfile, stream_name: str, allow_realm_admin: bool = False
user_profile: UserProfile, stream_name: str, require_content_access: bool = True
) -> tuple[Stream, Subscription | None]:
error = _("Invalid channel name '{channel_name}'").format(channel_name=stream_name)
try:
@@ -709,7 +735,7 @@ def access_stream_by_name(
user_profile,
stream,
error,
allow_realm_admin=allow_realm_admin,
require_content_access=require_content_access,
)
return (stream, sub)
@@ -883,7 +909,7 @@ def bulk_can_remove_subscribers_from_streams(
assert stream.recipient_id is not None
is_subscribed = stream.recipient_id in sub_recipient_ids
if not check_basic_stream_access(
user_profile, stream, is_subscribed=is_subscribed, allow_realm_admin=True
user_profile, stream, is_subscribed=is_subscribed, require_content_access=False
):
return False

View File

@@ -72,6 +72,7 @@ from zerver.lib.stream_traffic import (
from zerver.lib.streams import (
StreamDict,
StreamsCategorizedByPermissions,
UserGroupMembershipDetails,
access_stream_by_id,
access_stream_by_name,
can_access_stream_history,
@@ -3247,7 +3248,7 @@ class StreamAdminTest(ZulipTestCase):
are on.
"""
result = self.attempt_unsubscribe_of_principal(
query_count=18,
query_count=19,
target_users=[self.example_user("cordelia")],
is_realm_admin=True,
is_subbed=True,
@@ -3264,7 +3265,7 @@ class StreamAdminTest(ZulipTestCase):
streams you aren't on.
"""
result = self.attempt_unsubscribe_of_principal(
query_count=18,
query_count=19,
target_users=[self.example_user("cordelia")],
is_realm_admin=True,
is_subbed=False,
@@ -5991,7 +5992,7 @@ class SubscriptionAPITest(ZulipTestCase):
# Sends 3 peer-remove events, 2 unsubscribe events
# and 2 stream delete events for private streams.
with (
self.assert_database_query_count(18),
self.assert_database_query_count(20),
self.assert_memcached_count(3),
self.capture_send_event_calls(expected_num_events=7) as events,
):
@@ -7782,6 +7783,80 @@ class AccessStreamTest(ZulipTestCase):
access_stream_by_id(sipbtest, mit_stream.id)
access_stream_by_name(sipbtest, mit_stream.name)
def test_access_stream_allow_metadata_access_flag(self) -> None:
"""
A comprehensive security test for the access_stream_by_* API functions.
"""
# Create a private stream for which Hamlet is the only subscriber.
hamlet = self.example_user("hamlet")
stream_name = "new_private_stream"
self.login_user(hamlet)
self.subscribe_via_post(hamlet, [stream_name], invite_only=True)
stream = get_stream(stream_name, hamlet.realm)
othello = self.example_user("othello")
iago = self.example_user("iago")
# Realm admin cannot access the private stream
with self.assertRaisesRegex(JsonableError, "Invalid channel ID"):
access_stream_by_id(iago, stream.id)
with self.assertRaisesRegex(JsonableError, "Invalid channel name 'new_private_stream'"):
access_stream_by_name(iago, stream.name)
# Realm admins can access private stream if
# require_content_access set to False
access_stream_by_id(iago, stream.id, require_content_access=False)
access_stream_by_name(iago, stream.name, require_content_access=False)
# Normal unsubscribed user cannot access a private stream
with self.assertRaisesRegex(JsonableError, "Invalid channel ID"):
access_stream_by_id(othello, stream.id)
with self.assertRaisesRegex(JsonableError, "Invalid channel name 'new_private_stream'"):
access_stream_by_name(othello, stream.name)
# Normal unsubscribed user cannot access a private stream with
# require_content_access set to False
with self.assertRaisesRegex(JsonableError, "Invalid channel ID"):
access_stream_by_id(othello, stream.id, require_content_access=False)
with self.assertRaisesRegex(JsonableError, "Invalid channel name 'new_private_stream'"):
access_stream_by_name(othello, stream.name, require_content_access=False)
othello_group = check_add_user_group(
othello.realm, "user_profile_group", [othello], acting_user=othello
)
nobody_group = NamedUserGroup.objects.get(
name="role:nobody", is_system_group=True, realm=othello.realm
)
do_change_stream_group_based_setting(
stream,
"can_administer_channel_group",
othello_group,
acting_user=None,
)
# Channel admins can access private stream if
# require_content_access is set to False
access_stream_by_id(othello, stream.id, require_content_access=False)
access_stream_by_name(othello, stream.name, require_content_access=False)
do_change_stream_group_based_setting(
stream,
"can_administer_channel_group",
nobody_group,
acting_user=None,
)
do_change_stream_group_based_setting(
stream,
"can_add_subscribers_group",
othello_group,
acting_user=None,
)
# Users in `can_add_subscribers_group` can access private
# stream if require_content_access is set to True
access_stream_by_id(othello, stream.id, require_content_access=False)
access_stream_by_name(othello, stream.name, require_content_access=False)
def test_stream_access_by_guest(self) -> None:
guest_user_profile = self.example_user("polonius")
self.login_user(guest_user_profile)
@@ -7830,26 +7905,90 @@ class AccessStreamTest(ZulipTestCase):
# Even guest user should have access to web public channel.
self.assertEqual(
user_has_content_access(guest_user, web_public_stream, is_subscribed=False), True
user_has_content_access(
guest_user,
web_public_stream,
user_group_membership_details=UserGroupMembershipDetails(
user_recursive_group_ids=None
),
is_subscribed=False,
),
True,
)
# User should have access to private channel if they are
# subscribed to it
self.assertEqual(user_has_content_access(aaron, private_stream, is_subscribed=True), True)
self.assertEqual(user_has_content_access(aaron, private_stream, is_subscribed=False), False)
self.assertEqual(
user_has_content_access(
aaron,
private_stream,
user_group_membership_details=UserGroupMembershipDetails(
user_recursive_group_ids=None
),
is_subscribed=True,
),
True,
)
self.assertEqual(
user_has_content_access(
aaron,
private_stream,
user_group_membership_details=UserGroupMembershipDetails(
user_recursive_group_ids=None
),
is_subscribed=False,
),
False,
)
# Non guest user should have access to public channel
# regardless of their subscription to the channel.
self.assertEqual(user_has_content_access(aaron, public_stream, is_subscribed=True), True)
self.assertEqual(user_has_content_access(aaron, public_stream, is_subscribed=False), True)
self.assertEqual(
user_has_content_access(
aaron,
public_stream,
user_group_membership_details=UserGroupMembershipDetails(
user_recursive_group_ids=None
),
is_subscribed=True,
),
True,
)
self.assertEqual(
user_has_content_access(
aaron,
public_stream,
user_group_membership_details=UserGroupMembershipDetails(
user_recursive_group_ids=None
),
is_subscribed=False,
),
True,
)
# Guest user should have access to public channel only if they
# are subscribed to it.
self.assertEqual(
user_has_content_access(guest_user, public_stream, is_subscribed=False), False
user_has_content_access(
guest_user,
public_stream,
user_group_membership_details=UserGroupMembershipDetails(
user_recursive_group_ids=None
),
is_subscribed=False,
),
False,
)
self.assertEqual(
user_has_content_access(guest_user, public_stream, is_subscribed=True), True
user_has_content_access(
guest_user,
public_stream,
user_group_membership_details=UserGroupMembershipDetails(
user_recursive_group_ids=None
),
is_subscribed=True,
),
True,
)
# User should be able to access private channel if they are
@@ -7862,7 +8001,17 @@ class AccessStreamTest(ZulipTestCase):
aaron_group,
acting_user=None,
)
self.assertEqual(user_has_content_access(aaron, private_stream, is_subscribed=False), True)
self.assertEqual(
user_has_content_access(
aaron,
private_stream,
user_group_membership_details=UserGroupMembershipDetails(
user_recursive_group_ids=None
),
is_subscribed=False,
),
True,
)
nobody_group = NamedUserGroup.objects.get(
name="role:nobody", realm=realm, is_system_group=True
)
@@ -7883,8 +8032,28 @@ class AccessStreamTest(ZulipTestCase):
aaron_group,
acting_user=None,
)
self.assertEqual(user_has_content_access(aaron, private_stream, is_subscribed=False), False)
self.assertEqual(user_has_content_access(aaron, private_stream, is_subscribed=True), True)
self.assertEqual(
user_has_content_access(
aaron,
private_stream,
user_group_membership_details=UserGroupMembershipDetails(
user_recursive_group_ids=None
),
is_subscribed=False,
),
False,
)
self.assertEqual(
user_has_content_access(
aaron,
private_stream,
user_group_membership_details=UserGroupMembershipDetails(
user_recursive_group_ids=None
),
is_subscribed=True,
),
True,
)
class StreamTrafficTest(ZulipTestCase):

View File

@@ -404,7 +404,7 @@ def update_realm(
new_moderation_request_channel_id = None
if moderation_request_channel_id >= 0:
(new_moderation_request_channel_id, sub) = access_stream_by_id(
user_profile, moderation_request_channel_id, allow_realm_admin=True
user_profile, moderation_request_channel_id, require_content_access=False
)
do_set_realm_moderation_request_channel(
realm,
@@ -421,7 +421,9 @@ def update_realm(
new_stream_announcements_stream_new = None
if new_stream_announcements_stream_id >= 0:
(new_stream_announcements_stream_new, sub) = access_stream_by_id(
user_profile, new_stream_announcements_stream_id, allow_realm_admin=True
user_profile,
new_stream_announcements_stream_id,
require_content_access=False,
)
do_set_realm_new_stream_announcements_stream(
realm,
@@ -438,7 +440,7 @@ def update_realm(
new_signup_announcements_stream = None
if signup_announcements_stream_id >= 0:
(new_signup_announcements_stream, sub) = access_stream_by_id(
user_profile, signup_announcements_stream_id, allow_realm_admin=True
user_profile, signup_announcements_stream_id, require_content_access=False
)
do_set_realm_signup_announcements_stream(
realm,
@@ -455,7 +457,9 @@ def update_realm(
new_zulip_update_announcements_stream = None
if zulip_update_announcements_stream_id >= 0:
(new_zulip_update_announcements_stream, sub) = access_stream_by_id(
user_profile, zulip_update_announcements_stream_id, allow_realm_admin=True
user_profile,
zulip_update_announcements_stream_id,
require_content_access=False,
)
do_set_realm_zulip_update_announcements_stream(
realm,

View File

@@ -62,6 +62,7 @@ from zerver.lib.retention import parse_message_retention_days
from zerver.lib.stream_traffic import get_streams_traffic
from zerver.lib.streams import (
StreamDict,
UserGroupMembershipDetails,
access_default_stream_group_by_id,
access_stream_by_id,
access_stream_by_name,
@@ -244,7 +245,7 @@ def remove_default_stream(
(stream, sub) = access_stream_by_id(
user_profile,
stream_id,
allow_realm_admin=True,
require_content_access=False,
)
do_remove_default_stream(stream)
return json_success(request)
@@ -411,9 +412,17 @@ def update_stream_backend(
if validate_group_setting_value_change(
current_setting_api_value, new_setting_value, expected_current_setting_value
):
user_group_membership_details = UserGroupMembershipDetails(
user_recursive_group_ids=None
)
if (
setting_name in Stream.stream_permission_group_settings_requiring_content_access
and not user_has_content_access(user_profile, stream, is_subscribed=sub is not None)
and not user_has_content_access(
user_profile,
stream,
user_group_membership_details,
is_subscribed=sub is not None,
)
):
raise JsonableError(_("Invalid channel ID"))
with transaction.atomic(durable=True):
@@ -889,7 +898,7 @@ def get_subscribers_backend(
(stream, sub) = access_stream_by_id(
user_profile,
stream_id,
allow_realm_admin=True,
require_content_access=False,
)
subscribers = get_subscriber_ids(stream, user_profile)
@@ -931,7 +940,7 @@ def get_stream_backend(
*,
stream_id: PathOnly[int],
) -> HttpResponse:
(stream, sub) = access_stream_by_id(user_profile, stream_id, allow_realm_admin=True)
(stream, sub) = access_stream_by_id(user_profile, stream_id, require_content_access=False)
recent_traffic = get_streams_traffic({stream.id}, user_profile.realm)
setting_groups_dict = get_group_setting_value_dict_for_streams([stream])

View File

@@ -859,7 +859,7 @@ def get_subscription_backend(
stream_id: PathOnly[Json[int]],
) -> HttpResponse:
target_user = access_user_by_id(user_profile, user_id, for_admin=False)
(stream, sub) = access_stream_by_id(user_profile, stream_id, allow_realm_admin=True)
(stream, sub) = access_stream_by_id(user_profile, stream_id, require_content_access=False)
subscription_status = {"is_subscribed": subscribed_to_stream(target_user, stream_id)}