diff --git a/api_docs/changelog.md b/api_docs/changelog.md index ea54b848af..12f7e4254a 100644 --- a/api_docs/changelog.md +++ b/api_docs/changelog.md @@ -20,6 +20,19 @@ format used by the Zulip server that they are interacting with. ## Changes in Zulip 8.0 +**Feature level 209** + +* `PATCH /realm`, [`POST /register`](/api/register-queue), + [`GET /events`](/api/get-events): Added `create_multiuse_invite_group` + realm setting, which is the ID of the user group whose members can + create [reusable invitation links](/help/invite-new-users#create-a-reusable-invitation-link) + to an organization. Previously, only admin users could create these + links. + +* `POST /invites/multiuse`: Non-admin users can now use this endpoint + to create reusable invitation links. Previously, this endpoint was + restricted to admin users only. + **Feature level 208** * [`POST /users/me/subscriptions`](/api/subscribe), diff --git a/zerver/actions/create_realm.py b/zerver/actions/create_realm.py index 31e43cc330..9be0bec244 100644 --- a/zerver/actions/create_realm.py +++ b/zerver/actions/create_realm.py @@ -16,7 +16,10 @@ from zerver.actions.realm_settings import ( from zerver.lib.bulk_create import create_users from zerver.lib.server_initialization import create_internal_realm, server_initialized from zerver.lib.streams import ensure_stream, get_signups_stream -from zerver.lib.user_groups import create_system_user_groups_for_realm +from zerver.lib.user_groups import ( + create_system_user_groups_for_realm, + get_role_based_system_groups_dict, +) from zerver.models import ( DefaultStream, PreregistrationRealm, @@ -115,6 +118,17 @@ def set_realm_permissions_based_on_org_type(realm: Realm) -> None: realm.move_messages_between_streams_policy = Realm.POLICY_MODERATORS_ONLY +@transaction.atomic(savepoint=False) +def set_default_for_realm_permission_group_settings(realm: Realm) -> None: + system_groups_dict = get_role_based_system_groups_dict(realm) + + for setting_name, permissions_configuration in Realm.REALM_PERMISSION_GROUP_SETTINGS.items(): + group_name = permissions_configuration.default_group_name + setattr(realm, setting_name, system_groups_dict[group_name]) + + realm.save(update_fields=list(Realm.REALM_PERMISSION_GROUP_SETTINGS.keys())) + + def setup_realm_internal_bots(realm: Realm) -> None: """Create this realm's internal bots. @@ -204,6 +218,12 @@ def do_create_realm( ) set_realm_permissions_based_on_org_type(realm) + + # For now a dummy value of -1 is given to groups fields which + # is changed later before the transaction is committed. + for permissions_configuration in Realm.REALM_PERMISSION_GROUP_SETTINGS.values(): + setattr(realm, permissions_configuration.id_field_name, -1) + realm.save() RealmAuditLog.objects.create( @@ -230,6 +250,7 @@ def do_create_realm( ) create_system_user_groups_for_realm(realm) + set_default_for_realm_permission_group_settings(realm) # We create realms with all authentications methods enabled by default. RealmAuthenticationMethod.objects.bulk_create( diff --git a/zerver/actions/realm_settings.py b/zerver/actions/realm_settings.py index 6f6c58762b..6a40cd49f5 100644 --- a/zerver/actions/realm_settings.py +++ b/zerver/actions/realm_settings.py @@ -31,6 +31,7 @@ from zerver.models import ( ScheduledEmail, Stream, Subscription, + UserGroup, UserProfile, active_user_ids, get_realm, @@ -101,6 +102,42 @@ def do_set_realm_property( update_users_in_full_members_system_group(realm, acting_user=acting_user) +@transaction.atomic(durable=True) +def do_change_realm_permission_group_setting( + realm: Realm, setting_name: str, user_group: UserGroup, *, acting_user: Optional[UserProfile] +) -> None: + """Takes in a realm object, the name of an attribute to update, the + user_group to update and and the user who initiated the update. + """ + assert setting_name in Realm.REALM_PERMISSION_GROUP_SETTINGS + old_user_group_id = getattr(realm, setting_name).id + + setattr(realm, setting_name, user_group) + realm.save(update_fields=[setting_name]) + + event = dict( + type="realm", + op="update_dict", + property="default", + data={setting_name: user_group.id}, + ) + + send_event_on_commit(realm, event, active_user_ids(realm.id)) + + event_time = timezone_now() + RealmAuditLog.objects.create( + realm=realm, + event_type=RealmAuditLog.REALM_PROPERTY_CHANGED, + event_time=event_time, + acting_user=acting_user, + extra_data={ + RealmAuditLog.OLD_VALUE: old_user_group_id, + RealmAuditLog.NEW_VALUE: user_group.id, + "property": setting_name, + }, + ) + + def parse_and_set_setting_value_if_required( realm: Realm, setting_name: str, value: Union[int, str], *, acting_user: Optional[UserProfile] ) -> Tuple[Optional[int], bool]: diff --git a/zerver/lib/event_schema.py b/zerver/lib/event_schema.py index e32dfe12c3..8c5cd858ed 100644 --- a/zerver/lib/event_schema.py +++ b/zerver/lib/event_schema.py @@ -955,6 +955,10 @@ night_logo_data = DictType( ] ) +group_setting_update_data_type = DictType( + required_keys=[], optional_keys=[("create_multiuse_invite_group", int)] +) + update_dict_data = UnionType( [ allow_message_editing_data, @@ -964,6 +968,7 @@ update_dict_data = UnionType( logo_data, message_content_edit_limit_seconds_data, night_logo_data, + group_setting_update_data_type, ] ) @@ -996,6 +1001,10 @@ def check_realm_update_dict( sub_type = edit_topic_policy_data elif "authentication_methods" in event["data"]: sub_type = authentication_data + elif any( + setting_name in event["data"] for setting_name in Realm.REALM_PERMISSION_GROUP_SETTINGS + ): + sub_type = group_setting_update_data_type else: raise AssertionError("unhandled fields in data") diff --git a/zerver/lib/events.py b/zerver/lib/events.py index b8f3483410..7a335016da 100644 --- a/zerver/lib/events.py +++ b/zerver/lib/events.py @@ -259,6 +259,12 @@ def fetch_initial_state_data( for property_name in Realm.property_types: state["realm_" + property_name] = getattr(realm, property_name) + for ( + setting_name, + permissions_configuration, + ) in Realm.REALM_PERMISSION_GROUP_SETTINGS.items(): + state["realm_" + setting_name] = getattr(realm, permissions_configuration.id_field_name) + # Most state is handled via the property_types framework; # these manual entries are for those realm settings that don't # fit into that framework. diff --git a/zerver/lib/import_realm.py b/zerver/lib/import_realm.py index 835e85c592..fbba5a628c 100644 --- a/zerver/lib/import_realm.py +++ b/zerver/lib/import_realm.py @@ -18,6 +18,7 @@ from psycopg2.extras import execute_values from psycopg2.sql import SQL, Identifier from analytics.models import RealmCount, StreamCount, UserCount +from zerver.actions.create_realm import set_default_for_realm_permission_group_settings from zerver.actions.realm_settings import do_change_realm_plan_type from zerver.actions.user_settings import do_change_avatar_fields from zerver.lib.avatar_hash import user_avatar_path_from_ids @@ -906,10 +907,10 @@ def import_uploads( # have to import the dependencies first.) # # * Client [no deps] -# * Realm [-notifications_stream] +# * Realm [-notifications_stream,-group_permissions] # * UserGroup # * Stream [only depends on realm] -# * Realm's notifications_stream +# * Realm's notifications_stream and group_permissions # * UserProfile, in order by ID to avoid bot loop issues # * Now can do all realm_tables # * Huddle @@ -948,12 +949,17 @@ def do_import_realm(import_dir: Path, subdomain: str, processes: int = 1) -> Rea bulk_import_client(data, Client, "zerver_client") - # We don't import the Stream model yet, since it depends on Realm, - # which isn't imported yet. But we need the Stream model IDs for - # notifications_stream. + # We don't import the Stream and UserGroup models yet, since + # they depend on Realm, which isn't imported yet. + # But we need the Stream and UserGroup model IDs for + # notifications_stream and group permissions, respectively update_model_ids(Stream, data, "stream") re_map_foreign_keys(data, "zerver_realm", "notifications_stream", related_table="stream") re_map_foreign_keys(data, "zerver_realm", "signup_notifications_stream", related_table="stream") + if "zerver_usergroup" in data: + update_model_ids(UserGroup, data, "usergroup") + for setting_name in Realm.REALM_PERMISSION_GROUP_SETTINGS: + re_map_foreign_keys(data, "zerver_realm", setting_name, related_table="usergroup") fix_datetime_fields(data, "zerver_realm") # Fix realm subdomain information @@ -968,10 +974,15 @@ def do_import_realm(import_dir: Path, subdomain: str, processes: int = 1) -> Rea with transaction.atomic(durable=True): realm = Realm(**realm_properties) + if "zerver_usergroup" not in data: + # For now a dummy value of -1 is given to groups fields which + # is changed later before the transaction is committed. + for permissions_configuration in Realm.REALM_PERMISSION_GROUP_SETTINGS.values(): + setattr(realm, permissions_configuration.id_field_name, -1) + realm.save() if "zerver_usergroup" in data: - update_model_ids(UserGroup, data, "usergroup") re_map_foreign_keys(data, "zerver_usergroup", "realm", related_table="realm") for setting_name in UserGroup.GROUP_PERMISSION_SETTINGS: re_map_foreign_keys( @@ -1003,6 +1014,9 @@ def do_import_realm(import_dir: Path, subdomain: str, processes: int = 1) -> Rea stream["rendered_description"] = render_stream_description(stream["description"], realm) bulk_import_model(data, Stream) + if "zerver_usergroup" not in data: + set_default_for_realm_permission_group_settings(realm) + # Remap the user IDs for notification_bot and friends to their # appropriate IDs on this server internal_realm = get_realm(settings.SYSTEM_BOT_REALM) diff --git a/zerver/lib/server_initialization.py b/zerver/lib/server_initialization.py index e887ea244e..8134e8df97 100644 --- a/zerver/lib/server_initialization.py +++ b/zerver/lib/server_initialization.py @@ -23,14 +23,23 @@ def server_initialized() -> bool: @transaction.atomic(durable=True) def create_internal_realm() -> None: + from zerver.actions.create_realm import set_default_for_realm_permission_group_settings from zerver.actions.users import do_change_can_forge_sender - realm = Realm.objects.create(string_id=settings.SYSTEM_BOT_REALM, name="System bot realm") + realm = Realm(string_id=settings.SYSTEM_BOT_REALM, name="System bot realm") + + # For now a dummy value of -1 is given to groups fields which + # is changed later before the transaction is committed. + for permissions_configuration in Realm.REALM_PERMISSION_GROUP_SETTINGS.values(): + setattr(realm, permissions_configuration.id_field_name, -1) + realm.save() + RealmAuditLog.objects.create( realm=realm, event_type=RealmAuditLog.REALM_CREATED, event_time=realm.date_created ) RealmUserDefault.objects.create(realm=realm) create_system_user_groups_for_realm(realm) + set_default_for_realm_permission_group_settings(realm) # We create realms with all authentications methods enabled by default. RealmAuthenticationMethod.objects.bulk_create( diff --git a/zerver/migrations/0469_realm_create_multiuse_invite_group.py b/zerver/migrations/0469_realm_create_multiuse_invite_group.py new file mode 100644 index 0000000000..20824e93c9 --- /dev/null +++ b/zerver/migrations/0469_realm_create_multiuse_invite_group.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.1 on 2023-05-31 07:28 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("zerver", "0468_rename_followup_day_email_templates"), + ] + + operations = [ + migrations.AddField( + model_name="realm", + name="create_multiuse_invite_group", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.RESTRICT, + related_name="+", + to="zerver.usergroup", + ), + ), + ] diff --git a/zerver/migrations/0470_set_default_value_for_create_multiuse_invite_group.py b/zerver/migrations/0470_set_default_value_for_create_multiuse_invite_group.py new file mode 100644 index 0000000000..6a82d6afea --- /dev/null +++ b/zerver/migrations/0470_set_default_value_for_create_multiuse_invite_group.py @@ -0,0 +1,46 @@ +# Generated by Django 4.2.1 on 2023-06-03 10:53 + +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor +from django.db.migrations.state import StateApps + + +def set_default_value_for_create_multiuse_invite_group( + apps: StateApps, schema_editor: BaseDatabaseSchemaEditor +) -> None: + Realm = apps.get_model("zerver", "Realm") + UserGroup = apps.get_model("zerver", "UserGroup") + + UserGroup.ADMINISTRATORS_GROUP_NAME = "role:administrators" + + for realm in Realm.objects.all(): + if realm.create_multiuse_invite_group is not None: + continue + + # Prior to the new create_multiuse_invite_group field being + # created, multi-use invitation links could only be created + # and managed by administrators, regardless of + # invite_to_realm_policy. We replicate that policy for the + # initial value of the new setting. + admins_group = UserGroup.objects.get( + name=UserGroup.ADMINISTRATORS_GROUP_NAME, realm=realm, is_system_group=True + ) + realm.create_multiuse_invite_group = admins_group + + realm.save(update_fields=["create_multiuse_invite_group"]) + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("zerver", "0469_realm_create_multiuse_invite_group"), + ] + + operations = [ + migrations.RunPython( + set_default_value_for_create_multiuse_invite_group, + elidable=True, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/zerver/migrations/0471_alter_realm_create_multiuse_invite_group.py b/zerver/migrations/0471_alter_realm_create_multiuse_invite_group.py new file mode 100644 index 0000000000..82d4bdd082 --- /dev/null +++ b/zerver/migrations/0471_alter_realm_create_multiuse_invite_group.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.1 on 2023-06-06 08:26 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("zerver", "0470_set_default_value_for_create_multiuse_invite_group"), + ] + + operations = [ + migrations.AlterField( + model_name="realm", + name="create_multiuse_invite_group", + field=models.ForeignKey( + on_delete=django.db.models.deletion.RESTRICT, + related_name="+", + to="zerver.usergroup", + ), + ), + ] diff --git a/zerver/models.py b/zerver/models.py index 49b682e038..1e9228e92e 100644 --- a/zerver/models.py +++ b/zerver/models.py @@ -409,6 +409,11 @@ class Realm(models.Model): # type: ignore[django-manager-missing] # django-stub # Who in the organization is allowed to invite other users to organization. invite_to_realm_policy = models.PositiveSmallIntegerField(default=POLICY_MEMBERS_ONLY) + # UserGroup whose members are allowed to create invite link. + create_multiuse_invite_group = models.ForeignKey( + "UserGroup", on_delete=models.RESTRICT, related_name="+" + ) + # Who in the organization is allowed to invite other users to streams. invite_to_stream_policy = models.PositiveSmallIntegerField(default=POLICY_MEMBERS_ONLY) @@ -704,6 +709,9 @@ class Realm(models.Model): # type: ignore[django-manager-missing] # django-stub # they will not be available regardless of users' personal settings. enable_read_receipts = models.BooleanField(default=False) + # Duplicates of names for system group; TODO: Clean this up. + ADMINISTRATORS_GROUP_NAME = "role:administrators" + # Define the types of the various automatically managed properties property_types: Dict[str, Union[type, Tuple[type, ...]]] = dict( add_custom_emoji_policy=int, @@ -751,6 +759,17 @@ class Realm(models.Model): # type: ignore[django-manager-missing] # django-stub wildcard_mention_policy=int, ) + REALM_PERMISSION_GROUP_SETTINGS: Dict[str, GroupPermissionSetting] = dict( + create_multiuse_invite_group=GroupPermissionSetting( + require_system_group=True, + allow_internet_group=False, + allow_owners_group=False, + allow_nobody_group=True, + default_group_name=ADMINISTRATORS_GROUP_NAME, + id_field_name="create_multiuse_invite_group_id", + ), + ) + DIGEST_WEEKDAY_VALUES = [0, 1, 2, 3, 4, 5, 6] # Icon is the square mobile icon. @@ -2089,8 +2108,11 @@ class UserProfile(AbstractBaseUser, PermissionsMixin, UserBaseSettings): # type return False def has_permission(self, policy_name: str) -> bool: + from zerver.lib.user_groups import is_user_in_group + if policy_name not in [ "add_custom_emoji_policy", + "create_multiuse_invite_group", "create_private_stream_policy", "create_public_stream_policy", "create_web_public_stream_policy", @@ -2103,6 +2125,10 @@ class UserProfile(AbstractBaseUser, PermissionsMixin, UserBaseSettings): # type ]: raise AssertionError("Invalid policy") + if policy_name in Realm.REALM_PERMISSION_GROUP_SETTINGS: + allowed_user_group = getattr(self.realm, policy_name) + return is_user_in_group(allowed_user_group, self) + policy_value = getattr(self.realm, policy_name) if policy_value == Realm.POLICY_NOBODY: return False @@ -2154,6 +2180,9 @@ class UserProfile(AbstractBaseUser, PermissionsMixin, UserBaseSettings): # type def can_invite_others_to_realm(self) -> bool: return self.has_permission("invite_to_realm_policy") + def can_create_multiuse_invite_to_realm(self) -> bool: + return self.has_permission("create_multiuse_invite_group") + def can_move_messages_between_streams(self) -> bool: return self.has_permission("move_messages_between_streams_policy") diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index d85f24c4ab..6fd106cd5a 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -3961,6 +3961,19 @@ paths: description: | The [policy](/api/roles-and-permissions#permission-levels) for which users can create bot users in this organization. + create_multiuse_invite_group: + type: integer + description: | + The ID of the [user group](/api/get-user-groups) whose members are + allowed to create [reusable invitation + links](/help/invite-new-users#create-a-reusable-invitation-link) + to the organization. + + This setting can currently only be set to user groups that are + system groups, except for the system groups named + `"role:internet"` and `"role:owners"`. + + **Changes**: New in Zulip 8.0 (feature level 209). create_public_stream_policy: type: integer description: | @@ -13258,6 +13271,19 @@ paths: [permission-level]: /api/roles-and-permissions#permission-levels [calc-full-member]: /api/roles-and-permissions#determining-if-a-user-is-a-full-member + realm_create_multiuse_invite_group: + type: integer + description: | + The ID of the [user group](/api/get-user-groups) whose members are + allowed to create [reusable invitation + links](/help/invite-new-users#create-a-reusable-invitation-link) + to the organization. + + This setting can currently only be set to user groups that are + system groups, except for the system groups named + `"role:internet"` and `"role:owners"`. + + **Changes**: New in Zulip 8.0 (feature level 209). realm_move_messages_between_streams_policy: type: integer description: | diff --git a/zerver/tests/test_events.py b/zerver/tests/test_events.py index 92d98af8c4..59c3c64c0b 100644 --- a/zerver/tests/test_events.py +++ b/zerver/tests/test_events.py @@ -71,6 +71,7 @@ from zerver.actions.realm_logo import do_change_logo_source from zerver.actions.realm_playgrounds import check_add_realm_playground, do_remove_realm_playground from zerver.actions.realm_settings import ( do_change_realm_org_type, + do_change_realm_permission_group_setting, do_change_realm_plan_type, do_deactivate_realm, do_set_realm_authentication_methods, @@ -2968,11 +2969,83 @@ class RealmPropertyActionTest(BaseAction): else: check_realm_update("events[0]", events[0], name) + def do_set_realm_permission_group_setting_test(self, setting_name: str) -> None: + all_system_user_groups = UserGroup.objects.filter( + realm=self.user_profile.realm, + is_system_group=True, + ) + + setting_permission_configuration = Realm.REALM_PERMISSION_GROUP_SETTINGS[setting_name] + + default_group_name = setting_permission_configuration.default_group_name + default_group = all_system_user_groups.get(name=default_group_name) + old_group_id = default_group.id + + now = timezone_now() + + do_change_realm_permission_group_setting( + self.user_profile.realm, + setting_name, + default_group, + acting_user=self.user_profile, + ) + + self.assertEqual( + RealmAuditLog.objects.filter( + realm=self.user_profile.realm, + event_type=RealmAuditLog.REALM_PROPERTY_CHANGED, + event_time__gte=now, + acting_user=self.user_profile, + ).count(), + 1, + ) + for user_group in all_system_user_groups: + if user_group.name == default_group_name: + continue + + now = timezone_now() + state_change_expected = True + num_events = 1 + new_group_id = user_group.id + + events = self.verify_action( + lambda: do_change_realm_permission_group_setting( + self.user_profile.realm, + setting_name, + user_group, + acting_user=self.user_profile, + ), + state_change_expected=state_change_expected, + num_events=num_events, + ) + + self.assertEqual( + RealmAuditLog.objects.filter( + realm=self.user_profile.realm, + event_type=RealmAuditLog.REALM_PROPERTY_CHANGED, + event_time__gte=now, + acting_user=self.user_profile, + extra_data={ + RealmAuditLog.OLD_VALUE: old_group_id, + RealmAuditLog.NEW_VALUE: new_group_id, + "property": setting_name, + }, + ).count(), + 1, + ) + check_realm_update_dict("events[0]", events[0]) + + old_group_id = new_group_id + def test_change_realm_property(self) -> None: for prop in Realm.property_types: with self.settings(SEND_DIGEST_EMAILS=True): self.do_set_realm_property_test(prop) + for prop in Realm.REALM_PERMISSION_GROUP_SETTINGS: + with self.settings(SEND_DIGEST_EMAILS=True): + self.do_set_realm_permission_group_setting_test(prop) + def do_set_realm_user_default_setting_test(self, name: str) -> None: bool_tests: List[bool] = [True, False, True] test_values: Dict[str, Any] = dict( diff --git a/zerver/tests/test_home.py b/zerver/tests/test_home.py index 7a566043aa..db6f7211e7 100644 --- a/zerver/tests/test_home.py +++ b/zerver/tests/test_home.py @@ -115,6 +115,7 @@ class HomeTest(ZulipTestCase): "realm_bot_creation_policy", "realm_bot_domain", "realm_bots", + "realm_create_multiuse_invite_group", "realm_create_private_stream_policy", "realm_create_public_stream_policy", "realm_create_web_public_stream_policy", diff --git a/zerver/tests/test_import_export.py b/zerver/tests/test_import_export.py index 7205fc5f78..e82dc074ca 100644 --- a/zerver/tests/test_import_export.py +++ b/zerver/tests/test_import_export.py @@ -1045,6 +1045,13 @@ class RealmImportExportTest(ExportFile): def get_active_stream_names(r: Realm) -> Set[str]: return {stream.name for stream in get_active_streams(r)} + @getter + def get_group_names_for_group_settings(r: Realm) -> Set[str]: + return { + getattr(r, permmission_name).name + for permmission_name in Realm.REALM_PERMISSION_GROUP_SETTINGS + } + # test recipients def get_recipient_stream(r: Realm) -> Recipient: recipient = Stream.objects.get(name="Verona", realm=r).recipient @@ -1559,6 +1566,12 @@ class RealmImportExportTest(ExportFile): data = read_json("realm.json") data.pop("zerver_usergroup") data.pop("zerver_realmauditlog") + + # User groups data is missing. So, all the realm group based settings + # should be None. + for setting_name in Realm.REALM_PERMISSION_GROUP_SETTINGS: + data["zerver_realm"][0][setting_name] = None + with open(export_fn("realm.json"), "wb") as f: f.write(orjson.dumps(data)) diff --git a/zerver/tests/test_invite.py b/zerver/tests/test_invite.py index 6f9a33f3ef..82f90c6475 100644 --- a/zerver/tests/test_invite.py +++ b/zerver/tests/test_invite.py @@ -37,7 +37,11 @@ from zerver.actions.invites import ( do_revoke_multi_use_invite, too_many_recent_realm_invites, ) -from zerver.actions.realm_settings import do_change_realm_plan_type, do_set_realm_property +from zerver.actions.realm_settings import ( + do_change_realm_permission_group_setting, + do_change_realm_plan_type, + do_set_realm_property, +) from zerver.actions.user_settings import do_change_full_name from zerver.actions.users import change_user_is_active from zerver.context_processors import common_context @@ -55,6 +59,7 @@ from zerver.models import ( Realm, ScheduledEmail, Stream, + UserGroup, UserMessage, UserProfile, get_realm, @@ -2444,22 +2449,62 @@ class MultiuseInviteTest(ZulipTestCase): self.assert_length(get_default_streams_for_realm_as_dicts(self.realm.id), 1) self.check_user_subscribed_only_to_streams("alice", []) - def test_only_admin_can_create_multiuse_link_api_call(self) -> None: - self.login("iago") - # Only admins should be able to create multiuse invites even if - # invite_to_realm_policy is set to Realm.POLICY_MEMBERS_ONLY. - self.realm.invite_to_realm_policy = Realm.POLICY_MEMBERS_ONLY - self.realm.save() - - result = self.client_post( - "/json/invites/multiuse", {"invite_expires_in_minutes": 2 * 24 * 60} + def test_create_multiuse_invite_group_setting(self) -> None: + realm = get_realm("zulip") + full_members_system_group = UserGroup.objects.get( + name=UserGroup.FULL_MEMBERS_GROUP_NAME, realm=realm, is_system_group=True ) + nobody_system_group = UserGroup.objects.get( + name=UserGroup.NOBODY_GROUP_NAME, realm=realm, is_system_group=True + ) + + # Default value of create_multiuse_invite_group is administrators + self.login("shiva") + result = self.client_post("/json/invites/multiuse") + self.assert_json_error(result, "Insufficient permission") + + self.login("iago") + result = self.client_post("/json/invites/multiuse") invite_link = self.assert_json_success(result)["invite_link"] self.check_user_able_to_register(self.nonreg_email("test"), invite_link) + do_change_realm_permission_group_setting( + realm, "create_multiuse_invite_group", full_members_system_group, acting_user=None + ) + self.login("hamlet") result = self.client_post("/json/invites/multiuse") - self.assert_json_error(result, "Must be an organization administrator") + invite_link = self.assert_json_success(result)["invite_link"] + self.check_user_able_to_register(self.nonreg_email("test1"), invite_link) + + self.login("desdemona") + do_change_realm_permission_group_setting( + realm, "create_multiuse_invite_group", nobody_system_group, acting_user=None + ) + result = self.client_post("/json/invites/multiuse") + self.assert_json_error(result, "Insufficient permission") + + def test_only_owner_can_change_create_multiuse_invite_group(self) -> None: + realm = get_realm("zulip") + full_members_system_group = UserGroup.objects.get( + name=UserGroup.FULL_MEMBERS_GROUP_NAME, realm=realm, is_system_group=True + ) + + self.login("iago") + result = self.client_patch( + "/json/realm", + {"create_multiuse_invite_group": orjson.dumps(full_members_system_group.id).decode()}, + ) + self.assert_json_error(result, "Must be an organization owner") + + self.login("desdemona") + result = self.client_patch( + "/json/realm", + {"create_multiuse_invite_group": orjson.dumps(full_members_system_group.id).decode()}, + ) + self.assert_json_success(result) + realm = get_realm("zulip") + self.assertEqual(realm.create_multiuse_invite_group_id, full_members_system_group.id) def test_multiuse_link_for_inviting_as_owner(self) -> None: self.login("iago") @@ -2483,6 +2528,78 @@ class MultiuseInviteTest(ZulipTestCase): invite_link = self.assert_json_success(result)["invite_link"] self.check_user_able_to_register(self.nonreg_email("test"), invite_link) + def test_multiuse_link_for_inviting_as_admin(self) -> None: + realm = get_realm("zulip") + full_members_system_group = UserGroup.objects.get( + name=UserGroup.FULL_MEMBERS_GROUP_NAME, realm=realm, is_system_group=True + ) + + do_change_realm_permission_group_setting( + realm, "create_multiuse_invite_group", full_members_system_group, acting_user=None + ) + + self.login("hamlet") + result = self.client_post( + "/json/invites/multiuse", + { + "invite_as": orjson.dumps(PreregistrationUser.INVITE_AS["REALM_ADMIN"]).decode(), + "invite_expires_in_minutes": 2 * 24 * 60, + }, + ) + self.assert_json_error(result, "Must be an organization administrator") + + self.login("iago") + result = self.client_post( + "/json/invites/multiuse", + { + "invite_as": orjson.dumps(PreregistrationUser.INVITE_AS["REALM_ADMIN"]).decode(), + "invite_expires_in_minutes": 2 * 24 * 60, + }, + ) + invite_link = self.assert_json_success(result)["invite_link"] + self.check_user_able_to_register(self.nonreg_email("test"), invite_link) + + def test_multiuse_link_for_inviting_as_moderator(self) -> None: + realm = get_realm("zulip") + full_members_system_group = UserGroup.objects.get( + name=UserGroup.FULL_MEMBERS_GROUP_NAME, realm=realm, is_system_group=True + ) + + do_change_realm_permission_group_setting( + realm, "create_multiuse_invite_group", full_members_system_group, acting_user=None + ) + + self.login("hamlet") + result = self.client_post( + "/json/invites/multiuse", + { + "invite_as": orjson.dumps(PreregistrationUser.INVITE_AS["MODERATOR"]).decode(), + "invite_expires_in_minutes": 2 * 24 * 60, + }, + ) + self.assert_json_error(result, "Must be an organization administrator") + + self.login("shiva") + result = self.client_post( + "/json/invites/multiuse", + { + "invite_as": orjson.dumps(PreregistrationUser.INVITE_AS["MODERATOR"]).decode(), + "invite_expires_in_minutes": 2 * 24 * 60, + }, + ) + self.assert_json_error(result, "Must be an organization administrator") + + self.login("iago") + result = self.client_post( + "/json/invites/multiuse", + { + "invite_as": orjson.dumps(PreregistrationUser.INVITE_AS["REALM_ADMIN"]).decode(), + "invite_expires_in_minutes": 2 * 24 * 60, + }, + ) + invite_link = self.assert_json_success(result)["invite_link"] + self.check_user_able_to_register(self.nonreg_email("test"), invite_link) + def test_create_multiuse_link_invalid_stream_api_call(self) -> None: self.login("iago") result = self.client_post( diff --git a/zerver/tests/test_realm.py b/zerver/tests/test_realm.py index 3f199ef25d..db4d6ff373 100644 --- a/zerver/tests/test_realm.py +++ b/zerver/tests/test_realm.py @@ -892,6 +892,14 @@ class RealmTest(ZulipTestCase): self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_LIMITED) + for ( + setting_name, + permissions_configuration, + ) in Realm.REALM_PERMISSION_GROUP_SETTINGS.items(): + self.assertEqual( + getattr(realm, setting_name).name, permissions_configuration.default_group_name + ) + def test_do_create_realm_with_keyword_arguments(self) -> None: date_created = timezone_now() - datetime.timedelta(days=100) realm = do_create_realm( @@ -1207,11 +1215,56 @@ class RealmAPITest(ZulipTestCase): realm = self.update_with_api(name, vals[0]) self.assertEqual(getattr(realm, name), vals[0]) + def do_test_realm_permission_group_setting_update_api(self, setting_name: str) -> None: + realm = get_realm("zulip") + + all_system_user_groups = UserGroup.objects.filter( + realm=realm, + is_system_group=True, + ) + + setting_permission_configuration = Realm.REALM_PERMISSION_GROUP_SETTINGS[setting_name] + + default_group_name = setting_permission_configuration.default_group_name + default_group = all_system_user_groups.get(name=default_group_name) + + self.set_up_db(setting_name, default_group) + + for user_group in all_system_user_groups: + if ( + ( + user_group.name == UserGroup.EVERYONE_ON_INTERNET_GROUP_NAME + and not setting_permission_configuration.allow_internet_group + ) + or ( + user_group.name == UserGroup.NOBODY_GROUP_NAME + and not setting_permission_configuration.allow_nobody_group + ) + or ( + user_group.name == UserGroup.OWNERS_GROUP_NAME + and not setting_permission_configuration.allow_owners_group + ) + ): + value = orjson.dumps(user_group.id).decode() + + result = self.client_patch("/json/realm", {setting_name: value}) + self.assert_json_error( + result, f"'{setting_name}' setting cannot be set to '{user_group.name}' group." + ) + continue + + realm = self.update_with_api(setting_name, user_group.id) + self.assertEqual(getattr(realm, setting_name), user_group) + def test_update_realm_properties(self) -> None: for prop in Realm.property_types: with self.subTest(property=prop): self.do_test_realm_update_api(prop) + for prop in Realm.REALM_PERMISSION_GROUP_SETTINGS: + with self.subTest(property=prop): + self.do_test_realm_permission_group_setting_update_api(prop) + # Not in Realm.property_types because org_type has # a unique RealmAuditLog event_type. def test_update_realm_org_type(self) -> None: diff --git a/zerver/views/invite.py b/zerver/views/invite.py index 38b089b814..ba5a80877e 100644 --- a/zerver/views/invite.py +++ b/zerver/views/invite.py @@ -184,7 +184,7 @@ def resend_user_invite_email( return json_success(request, data={"timestamp": timestamp}) -@require_realm_admin +@require_member_or_admin @has_request_variables def generate_multiuse_invite_backend( request: HttpRequest, @@ -200,7 +200,19 @@ def generate_multiuse_invite_backend( ), stream_ids: Sequence[int] = REQ(json_validator=check_list(check_int), default=[]), ) -> HttpResponse: - check_role_based_permissions(invite_as, user_profile, require_admin=True) + if not user_profile.can_create_multiuse_invite_to_realm(): + # Guest users case will not be handled here as it will + # be handled by the decorator above. + raise JsonableError(_("Insufficient permission")) + + require_admin = invite_as in [ + # Owners can only be invited by owners, checked by separate + # logic in check_role_based_permissions. + PreregistrationUser.INVITE_AS["REALM_OWNER"], + PreregistrationUser.INVITE_AS["REALM_ADMIN"], + PreregistrationUser.INVITE_AS["MODERATOR"], + ] + check_role_based_permissions(invite_as, user_profile, require_admin=require_admin) streams = [] for stream_id in stream_ids: diff --git a/zerver/views/realm.py b/zerver/views/realm.py index f57e3680ac..f4ae1b943f 100644 --- a/zerver/views/realm.py +++ b/zerver/views/realm.py @@ -10,6 +10,7 @@ from confirmation.models import Confirmation, ConfirmationKeyError, get_object_f from zerver.actions.create_realm import do_change_realm_subdomain from zerver.actions.realm_settings import ( do_change_realm_org_type, + do_change_realm_permission_group_setting, do_deactivate_realm, do_reactivate_realm, do_set_realm_authentication_methods, @@ -27,6 +28,7 @@ from zerver.lib.request import REQ, has_request_variables from zerver.lib.response import json_success from zerver.lib.retention import parse_message_retention_days from zerver.lib.streams import access_stream_by_id +from zerver.lib.user_groups import access_user_group_for_setting from zerver.lib.validator import ( check_bool, check_capped_string, @@ -60,6 +62,9 @@ def update_realm( invite_to_realm_policy: Optional[int] = REQ( json_validator=check_int_in(Realm.INVITE_TO_REALM_POLICY_TYPES), default=None ), + create_multiuse_invite_group_id: Optional[int] = REQ( + "create_multiuse_invite_group", json_validator=check_int, default=None + ), name_changes_disabled: Optional[bool] = REQ(json_validator=check_bool, default=None), email_changes_disabled: Optional[bool] = REQ(json_validator=check_bool, default=None), avatar_changes_disabled: Optional[bool] = REQ(json_validator=check_bool, default=None), @@ -188,7 +193,9 @@ def update_realm( ) if ( - invite_to_realm_policy is not None or invite_required is not None + invite_to_realm_policy is not None + or invite_required is not None + or create_multiuse_invite_group_id is not None ) and not user_profile.is_realm_owner: raise OrganizationOwnerRequiredError @@ -275,7 +282,16 @@ def update_realm( # TODO: It should be possible to deduplicate this function up # further by some more advanced usage of the # `REQ/has_request_variables` extraction. - req_vars = {k: v for k, v in list(locals().items()) if k in realm.property_types} + req_vars = {} + req_group_setting_vars = {} + + for k, v in list(locals().items()): + if k in realm.property_types: + req_vars[k] = v + + for permissions_configuration in Realm.REALM_PERMISSION_GROUP_SETTINGS.values(): + if k == permissions_configuration.id_field_name: + req_group_setting_vars[k] = v for k, v in list(req_vars.items()): if v is not None and getattr(realm, k) != v: @@ -285,6 +301,29 @@ def update_realm( else: data[k] = v + for setting_name, permissions_configuration in Realm.REALM_PERMISSION_GROUP_SETTINGS.items(): + setting_group_id_name = permissions_configuration.id_field_name + + assert setting_group_id_name in req_group_setting_vars + + if req_group_setting_vars[setting_group_id_name] is not None and req_group_setting_vars[ + setting_group_id_name + ] != getattr(realm, setting_group_id_name): + user_group_id = req_group_setting_vars[setting_group_id_name] + user_group = access_user_group_for_setting( + user_group_id, + user_profile, + setting_name=setting_name, + require_system_group=permissions_configuration.require_system_group, + allow_internet_group=permissions_configuration.allow_internet_group, + allow_owners_group=permissions_configuration.allow_owners_group, + allow_nobody_group=permissions_configuration.allow_nobody_group, + ) + do_change_realm_permission_group_setting( + realm, setting_name, user_group, acting_user=user_profile + ) + data[setting_name] = user_group_id + # The following realm properties do not fit the pattern above # authentication_methods is not supported by the do_set_realm_property # framework because it's tracked through the RealmAuthenticationMethod table.