From da9e4e6e54ddbd06e2c16db17eb7cb0678b5a786 Mon Sep 17 00:00:00 2001 From: Mateusz Mandera Date: Mon, 5 Feb 2024 23:52:25 +0100 Subject: [PATCH] backends: Implementation of restricting certain backends by plan. Only affects zulipchat, by being based on the BILLING_ENABLED setting. The restricted backends in this commit are - AzureAD - restricted to Standard plan - SAML - restricted to Plus plan, although it was already practically restricted due to requiring server-side configuration to be done by us This restriction is placed upon **enabling** a backend - so organizations that already have a backend enabled, will continue to be able to use it. This allows us to make exceptions and enable a backend for an org manually via the shell, and to grandfather organizations into keeping the backend they have been relying on. --- web/src/server_events_dispatch.js | 20 ++- web/src/settings_components.js | 15 +++ web/src/settings_org.js | 33 ++++- .../settings/admin_auth_methods_list.hbs | 3 +- web/tests/dispatch.test.js | 5 +- zerver/actions/create_realm.py | 5 +- zerver/actions/realm_settings.py | 92 +++++++++++++ zerver/data_import/import_util.py | 4 +- zerver/lib/events.py | 21 ++- zerver/lib/import_realm.py | 23 ++++ zerver/lib/server_initialization.py | 5 +- zerver/models/realms.py | 5 +- zerver/openapi/zulip.yaml | 30 ++++- zerver/tests/test_auth_backends.py | 42 ++++++ zerver/tests/test_home.py | 122 ++++++++++++++++++ zerver/tests/test_import_export.py | 46 +++++++ zerver/tests/test_realm.py | 71 ++++++++++ zerver/views/realm.py | 2 + zproject/backends.py | 32 ++++- 19 files changed, 538 insertions(+), 38 deletions(-) diff --git a/web/src/server_events_dispatch.js b/web/src/server_events_dispatch.js index 3cf8f3d576..06851b7c49 100644 --- a/web/src/server_events_dispatch.js +++ b/web/src/server_events_dispatch.js @@ -290,7 +290,20 @@ export function dispatch_normal_event(event) { switch (event.property) { case "default": for (const [key, value] of Object.entries(event.data)) { - realm["realm_" + key] = value; + if (key === "authentication_methods") { + for (const [auth_method, enabled] of Object.entries( + event.data.authentication_methods, + )) { + realm.realm_authentication_methods[auth_method].enabled = + enabled; + } + settings_org.populate_auth_methods( + event.data.authentication_methods, + ); + } else { + realm["realm_" + key] = value; + } + if (Object.hasOwn(realm_settings, key)) { settings_org.sync_realm_settings(key); } @@ -305,11 +318,6 @@ export function dispatch_normal_event(event) { message_live_update.rerender_messages_view(); } } - if (event.data.authentication_methods !== undefined) { - settings_org.populate_auth_methods( - event.data.authentication_methods, - ); - } break; case "icon": realm.realm_icon_url = event.data.icon_url; diff --git a/web/src/settings_components.js b/web/src/settings_components.js index b8e3fb2763..c82818a925 100644 --- a/web/src/settings_components.js +++ b/web/src/settings_components.js @@ -88,9 +88,24 @@ export function get_property_value(property_name, for_realm_default_settings, su return "no_restriction"; } + if (property_name === "realm_authentication_methods") { + return realm_authentication_methods_to_boolean_dict(); + } + return realm[property_name]; } +export function realm_authentication_methods_to_boolean_dict() { + const auth_method_to_bool = {}; + for (const [auth_method_name, auth_method_info] of Object.entries( + realm.realm_authentication_methods, + )) { + auth_method_to_bool[auth_method_name] = auth_method_info.enabled; + } + + return auth_method_to_bool; +} + export function extract_property_name($elem, for_realm_default_settings) { if (for_realm_default_settings) { // ID for realm_user_default_settings elements are of the form diff --git a/web/src/settings_org.js b/web/src/settings_org.js index 688ceb5e84..c82c6bf2bc 100644 --- a/web/src/settings_org.js +++ b/web/src/settings_org.js @@ -388,18 +388,29 @@ function can_configure_auth_methods() { return false; } -export function populate_auth_methods(auth_methods) { +export function populate_auth_methods(auth_method_to_bool_map) { if (!meta.loaded) { return; } const $auth_methods_list = $("#id_realm_authentication_methods").expectOne(); - auth_methods = settings_components.sort_object_by_key(auth_methods); + auth_method_to_bool_map = settings_components.sort_object_by_key(auth_method_to_bool_map); let rendered_auth_method_rows = ""; - for (const [auth_method, value] of Object.entries(auth_methods)) { - rendered_auth_method_rows += render_settings_admin_auth_methods_list({ + for (const [auth_method, value] of Object.entries(auth_method_to_bool_map)) { + // Certain authentication methods are not available to be enabled without + // purchasing a plan, so we need to disable them in this UI. + // The restriction only applies to **enabling** the auth method, so this + // logic is dependent on the current value. + // The reason for that is that if for any reason, the auth method is already + // enabled (for example, because it was manually enabled for the organization + // by request, as an exception) - the organization should be able to disable it + // if they don't want it anymore. + const cant_be_enabled = + !realm.realm_authentication_methods[auth_method].available && !value; + + const render_args = { method: auth_method, enabled: value, - disable_configure_auth_method: !can_configure_auth_methods(), + disable_configure_auth_method: !can_configure_auth_methods() || cant_be_enabled, // The negated character class regexp serves as an allowlist - the replace() will // remove *all* symbols *but* digits (\d) and lowecase letters (a-z), // so that we can make assumptions on this string elsewhere in the code. @@ -407,7 +418,14 @@ export function populate_auth_methods(auth_methods) { // 1) It contains at least one allowed symbol // 2) No two auth method names are identical after this allowlist filtering prefix: "id_authmethod" + auth_method.toLowerCase().replaceAll(/[^\da-z]/g, "") + "_", - }); + }; + + if (cant_be_enabled) { + render_args.unavailable_reason = + realm.realm_authentication_methods[auth_method].unavailable_reason; + } + + rendered_auth_method_rows += render_settings_admin_auth_methods_list(render_args); } $auth_methods_list.html(rendered_auth_method_rows); } @@ -967,7 +985,8 @@ export function build_page() { populate_realm_domains_label(realm.realm_domains); // Populate authentication methods table - populate_auth_methods(realm.realm_authentication_methods); + + populate_auth_methods(settings_components.realm_authentication_methods_to_boolean_dict()); for (const property_name of simple_dropdown_properties) { settings_components.set_property_dropdown_value(property_name); diff --git a/web/templates/settings/admin_auth_methods_list.hbs b/web/templates/settings/admin_auth_methods_list.hbs index cd8eb63486..1c91cc817e 100644 --- a/web/templates/settings/admin_auth_methods_list.hbs +++ b/web/templates/settings/admin_auth_methods_list.hbs @@ -4,5 +4,6 @@ prefix=prefix is_checked=enabled label=method - is_disabled=disable_configure_auth_method}} + is_disabled=disable_configure_auth_method + tooltip_text=unavailable_reason}} diff --git a/web/tests/dispatch.test.js b/web/tests/dispatch.test.js index fed8099430..bd3b03288b 100644 --- a/web/tests/dispatch.test.js +++ b/web/tests/dispatch.test.js @@ -564,13 +564,16 @@ run_test("realm settings", ({override}) => { realm.realm_allow_message_editing = false; realm.realm_message_content_edit_limit_seconds = 0; realm.realm_edit_topic_policy = 3; + realm.realm_authentication_methods = {Google: {enabled: false, available: true}}; override(settings_org, "populate_auth_methods", noop); dispatch(event); assert_same(realm.realm_create_multiuse_invite_group, 3); assert_same(realm.realm_allow_message_editing, true); assert_same(realm.realm_message_content_edit_limit_seconds, 5); assert_same(realm.realm_edit_topic_policy, 4); - assert_same(realm.realm_authentication_methods, {Google: true}); + assert_same(realm.realm_authentication_methods, { + Google: {enabled: true, available: true}, + }); event = event_fixtures.realm__update_dict__icon; override(realm_icon, "rerender", noop); diff --git a/zerver/actions/create_realm.py b/zerver/actions/create_realm.py index 5e3b8a6a1c..683bef0d58 100644 --- a/zerver/actions/create_realm.py +++ b/zerver/actions/create_realm.py @@ -34,7 +34,7 @@ from zerver.models import ( ) from zerver.models.realms import get_org_type_display_name, get_realm from zerver.models.users import get_system_bot -from zproject.backends import all_implemented_backend_names +from zproject.backends import all_default_backend_names if settings.CORPORATE_ENABLED: from corporate.lib.support import get_realm_support_url @@ -261,11 +261,10 @@ 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( [ RealmAuthenticationMethod(name=backend_name, realm=realm) - for backend_name in all_implemented_backend_names() + for backend_name in all_default_backend_names() ] ) diff --git a/zerver/actions/realm_settings.py b/zerver/actions/realm_settings.py index e26c2edb8e..cceeaa1675 100644 --- a/zerver/actions/realm_settings.py +++ b/zerver/actions/realm_settings.py @@ -210,6 +210,57 @@ def parse_and_set_setting_value_if_required( return parsed_value, setting_value_changed +def get_realm_authentication_methods_for_page_params_api( + realm: Realm, authentication_methods: Dict[str, bool] +) -> Dict[str, Any]: + # To avoid additional queries, this expects passing in the authentication_methods + # dictionary directly, which is useful when the caller already has to fetch it + # for other purposes - and that's the circumstance in which this function is + # currently used. We can trivially make this argument optional if needed. + + from zproject.backends import AUTH_BACKEND_NAME_MAP + + result_dict: Dict[str, Dict[str, Union[str, bool]]] = { + backend_name: {"enabled": enabled, "available": True} + for backend_name, enabled in authentication_methods.items() + } + + if not settings.BILLING_ENABLED: + return result_dict + + # The rest of the function is only for the mechanism of restricting + # certain backends based on the realm's plan type on Zulip Cloud. + + from corporate.models import CustomerPlan + + for backend_name in result_dict: + available_for = AUTH_BACKEND_NAME_MAP[backend_name].available_for_cloud_plans + + if available_for is not None and realm.plan_type not in available_for: + result_dict[backend_name]["available"] = False + + required_upgrade_plan_number = min( + set(available_for).intersection({Realm.PLAN_TYPE_STANDARD, Realm.PLAN_TYPE_PLUS}) + ) + if required_upgrade_plan_number == Realm.PLAN_TYPE_STANDARD: + required_upgrade_plan_name = CustomerPlan.name_from_tier( + CustomerPlan.TIER_CLOUD_STANDARD + ) + else: + assert required_upgrade_plan_number == Realm.PLAN_TYPE_PLUS + required_upgrade_plan_name = CustomerPlan.name_from_tier( + CustomerPlan.TIER_CLOUD_PLUS + ) + + result_dict[backend_name]["unavailable_reason"] = _( + "You need to upgrade to the {required_upgrade_plan_name} plan to use this authentication method." + ).format(required_upgrade_plan_name=required_upgrade_plan_name) + else: + result_dict[backend_name]["available"] = True + + return result_dict + + def validate_authentication_methods_dict_from_api( realm: Realm, authentication_methods: Dict[str, bool] ) -> None: @@ -222,6 +273,32 @@ def validate_authentication_methods_dict_from_api( ) ) + if settings.BILLING_ENABLED: + validate_plan_for_authentication_methods(realm, authentication_methods) + + +def validate_plan_for_authentication_methods( + realm: Realm, authentication_methods: Dict[str, bool] +) -> None: + from zproject.backends import AUTH_BACKEND_NAME_MAP + + old_authentication_methods = realm.authentication_methods_dict() + newly_enabled_authentication_methods = { + name + for name, enabled in authentication_methods.items() + if enabled and not old_authentication_methods.get(name, False) + } + for name in newly_enabled_authentication_methods: + available_for = AUTH_BACKEND_NAME_MAP[name].available_for_cloud_plans + if available_for is not None and realm.plan_type not in available_for: + # This should only be feasible via the API, since app UI should prevent + # trying to enable an unavailable authentication method. + raise JsonableError( + _("Authentication method {name} is not available on your current plan.").format( + name=name + ) + ) + def do_set_realm_authentication_methods( realm: Realm, authentication_methods: Dict[str, bool], *, acting_user: Optional[UserProfile] @@ -561,6 +638,8 @@ def do_change_realm_org_type( def do_change_realm_plan_type( realm: Realm, plan_type: int, *, acting_user: Optional[UserProfile] ) -> None: + from zproject.backends import AUTH_BACKEND_NAME_MAP + old_value = realm.plan_type if plan_type == Realm.PLAN_TYPE_LIMITED: @@ -582,6 +661,19 @@ def do_change_realm_plan_type( realm, "can_access_all_users_group", everyone_system_group, acting_user=acting_user ) + # If downgrading, disable authentication methods that are not available on the new plan. + if settings.BILLING_ENABLED: + realm_authentication_methods = realm.authentication_methods_dict() + for backend_name, enabled in realm_authentication_methods.items(): + if enabled and plan_type < old_value: + available_for = AUTH_BACKEND_NAME_MAP[backend_name].available_for_cloud_plans + if available_for is not None and plan_type not in available_for: + realm_authentication_methods[backend_name] = False + if realm_authentication_methods != realm.authentication_methods_dict(): + do_set_realm_authentication_methods( + realm, realm_authentication_methods, acting_user=acting_user + ) + realm.plan_type = plan_type realm.save(update_fields=["plan_type"]) RealmAuditLog.objects.create( diff --git a/zerver/data_import/import_util.py b/zerver/data_import/import_util.py index 1f6f556873..6940efc994 100644 --- a/zerver/data_import/import_util.py +++ b/zerver/data_import/import_util.py @@ -41,7 +41,7 @@ from zerver.models import ( Subscription, UserProfile, ) -from zproject.backends import all_implemented_backend_names +from zproject.backends import all_default_backend_names # stubs ZerverFieldsT: TypeAlias = Dict[str, Any] @@ -378,7 +378,7 @@ def build_realm( zerver_realmplayground=[], zerver_realmauthenticationmethod=[ {"realm": realm_id, "name": name, "id": i} - for i, name in enumerate(all_implemented_backend_names(), start=1) + for i, name in enumerate(all_default_backend_names(), start=1) ], ) return realm diff --git a/zerver/lib/events.py b/zerver/lib/events.py index 5c5f5fca62..4682a5bcf8 100644 --- a/zerver/lib/events.py +++ b/zerver/lib/events.py @@ -10,6 +10,7 @@ from django.utils.translation import gettext as _ from version import API_FEATURE_LEVEL, ZULIP_MERGE_BASE, ZULIP_VERSION from zerver.actions.default_streams import default_stream_groups_to_dicts_sorted +from zerver.actions.realm_settings import get_realm_authentication_methods_for_page_params_api from zerver.actions.users import get_owned_bot_dicts from zerver.lib import emoji from zerver.lib.alert_words import user_alert_words @@ -270,7 +271,11 @@ def fetch_initial_state_data( # these manual entries are for those realm settings that don't # fit into that framework. realm_authentication_methods_dict = realm.authentication_methods_dict() - state["realm_authentication_methods"] = realm_authentication_methods_dict + state["realm_authentication_methods"] = ( + get_realm_authentication_methods_for_page_params_api( + realm, realm_authentication_methods_dict + ) + ) # We pretend these features are disabled because anonymous # users can't access them. In the future, we may want to move @@ -1193,13 +1198,19 @@ def apply_event( ) elif event["op"] == "update_dict": for key, value in event["data"].items(): - state["realm_" + key] = value - # It's a bit messy, but this is where we need to - # update the state for whether password authentication - # is enabled on this server. if key == "authentication_methods": + state_realm_authentication_methods = state["realm_authentication_methods"] + for auth_method, enabled in value.items(): + state_realm_authentication_methods[auth_method]["enabled"] = enabled + + # It's a bit messy, but this is where we need to + # update the state for whether password authentication + # is enabled on this server. state["realm_password_auth_enabled"] = value["Email"] or value["LDAP"] state["realm_email_auth_enabled"] = value["Email"] + + else: + state["realm_" + key] = value elif event["op"] == "deactivated": # The realm has just been deactivated. If our request had # arrived a moment later, we'd have rendered the diff --git a/zerver/lib/import_realm.py b/zerver/lib/import_realm.py index b121bdcdf7..fc8e9c7acf 100644 --- a/zerver/lib/import_realm.py +++ b/zerver/lib/import_realm.py @@ -81,6 +81,7 @@ from zerver.models.groups import SystemGroups from zerver.models.realms import get_realm from zerver.models.recipients import get_huddle_hash from zerver.models.users import get_system_bot, get_user_profile_by_id +from zproject.backends import AUTH_BACKEND_NAME_MAP realm_tables = [ ("zerver_realmauthenticationmethod", RealmAuthenticationMethod, "realmauthenticationmethod"), @@ -897,6 +898,24 @@ def import_uploads( future.result() +def disable_restricted_authentication_methods(data: TableData) -> None: + """ + Should run only with settings.BILLING_ENABLED. Ensures that we only + enable authentication methods that are available without needing a plan. + If the organization upgrades to a paid plan, or gets a sponsorship, + they can enable the restricted authentication methods in their settings. + """ + realm_authentication_methods = data["zerver_realmauthenticationmethod"] + non_restricted_methods = [] + for auth_method in realm_authentication_methods: + if AUTH_BACKEND_NAME_MAP[auth_method["name"]].available_for_cloud_plans is None: + non_restricted_methods.append(auth_method) + else: + logging.warning("Dropped restricted authentication method: %s", auth_method["name"]) + + data["zerver_realmauthenticationmethod"] = non_restricted_methods + + # Importing data suffers from a difficult ordering problem because of # models that reference each other circularly. Here is a correct order. # @@ -1078,6 +1097,10 @@ def do_import_realm(import_dir: Path, subdomain: str, processes: int = 1) -> Rea re_map_foreign_keys(data, "zerver_defaultstream", "stream", related_table="stream") re_map_foreign_keys(data, "zerver_realmemoji", "author", related_table="user_profile") + + if settings.BILLING_ENABLED: + disable_restricted_authentication_methods(data) + for table, model, related_table in realm_tables: re_map_foreign_keys(data, table, "realm", related_table="realm") update_model_ids(model, data, related_table) diff --git a/zerver/lib/server_initialization.py b/zerver/lib/server_initialization.py index 9d4261388c..d75e3139e8 100644 --- a/zerver/lib/server_initialization.py +++ b/zerver/lib/server_initialization.py @@ -14,7 +14,7 @@ from zerver.models import ( ) from zerver.models.clients import get_client from zerver.models.users import get_system_bot -from zproject.backends import all_implemented_backend_names +from zproject.backends import all_default_backend_names def server_initialized() -> bool: @@ -41,11 +41,10 @@ def create_internal_realm() -> None: 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( [ RealmAuthenticationMethod(name=backend_name, realm=realm) - for backend_name in all_implemented_backend_names() + for backend_name in all_default_backend_names() ] ) diff --git a/zerver/models/realms.py b/zerver/models/realms.py index f0b0f9347c..1339958007 100644 --- a/zerver/models/realms.py +++ b/zerver/models/realms.py @@ -711,13 +711,12 @@ class Realm(models.Model): # type: ignore[django-manager-missing] # django-stub on the server, this will not return an entry for "Email").""" # This mapping needs to be imported from here due to the cyclic # dependency. - from zproject.backends import AUTH_BACKEND_NAME_MAP, all_implemented_backend_names + from zproject.backends import AUTH_BACKEND_NAME_MAP ret: Dict[str, bool] = {} supported_backends = [type(backend) for backend in supported_auth_backends()] - for backend_name in all_implemented_backend_names(): - backend_class = AUTH_BACKEND_NAME_MAP[backend_name] + for backend_name, backend_class in AUTH_BACKEND_NAME_MAP.items(): if backend_class in supported_backends: ret[backend_name] = False for realm_authentication_method in RealmAuthenticationMethod.objects.filter( diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index 0eec89db23..0db4580b6e 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -14529,15 +14529,35 @@ paths: realm_authentication_methods: type: object additionalProperties: + type: object + properties: + enabled: + type: boolean + description: | + Boolean describing whether the authentication method (i.e. its key) + is enabled in this organization. + available: + type: boolean + description: | + Boolean describing whether the authentication method is available for use. + If false, the organization is not eligible to enable the authentication + method. + unavailable_reason: + type: string + description: | + Reason why the authentication method is unavailable. This field is optional + and is only present when 'available' is false. + additionalProperties: false description: | - Boolean describing whether the authentication method (i.e. its key) - is enabled in this organization. - type: boolean + Dictionary describing the properties of the named authentication method for the + organization - its enabled status and availability for use by the + organization. description: | Present if `realm` is present in `fetch_event_types`. - Dictionary of authentication method keys with boolean values that - describe whether the named authentication method is enabled for the + Dictionary of authentication method keys mapped to dictionaries that + describe the properties of the named authentication method for the + organization - its enabled status and availability for use by the organization. Clients should use this to implement server-settings UI to change which diff --git a/zerver/tests/test_auth_backends.py b/zerver/tests/test_auth_backends.py index 1eb2bcbee9..9571b7e421 100644 --- a/zerver/tests/test_auth_backends.py +++ b/zerver/tests/test_auth_backends.py @@ -7200,6 +7200,48 @@ class TestAdminSetBackends(ZulipTestCase): "Invalid authentication method: NoSuchBackend. Valid methods are: ['Dev', 'Email']", ) + with self.settings( + AUTHENTICATION_BACKENDS=( + "zproject.backends.EmailAuthBackend", + "zproject.backends.DevAuthBackend", + "zproject.backends.AzureADAuthBackend", + ), + ): + realm = get_realm("zulip") + self.assertEqual( + realm.authentication_methods_dict(), {"Dev": True, "Email": True, "AzureAD": True} + ) + + # AzureAD is not available without a Standard plan, but we start off with it enabled. + # Disabling the backend should work. + result = self.client_patch( + "/json/realm", + { + "authentication_methods": orjson.dumps( + # Github is not a supported authentication backend right now. + {"Email": True, "Dev": True, "AzureAD": False} + ).decode() + }, + ) + self.assert_json_success(result) + self.assertEqual( + realm.authentication_methods_dict(), {"Dev": True, "Email": True, "AzureAD": False} + ) + + # However, due to the lack of the necessary plan, enabling will fail. + result = self.client_patch( + "/json/realm", + { + "authentication_methods": orjson.dumps( + # Github is not a supported authentication backend right now. + {"Email": True, "Dev": True, "AzureAD": True} + ).decode() + }, + ) + self.assert_json_error( + result, "Authentication method AzureAD is not available on your current plan." + ) + class EmailValidatorTestCase(ZulipTestCase): def test_valid_email(self) -> None: diff --git a/zerver/tests/test_home.py b/zerver/tests/test_home.py index c3ee11e87a..a165b00c46 100644 --- a/zerver/tests/test_home.py +++ b/zerver/tests/test_home.py @@ -369,6 +369,128 @@ class HomeTest(ZulipTestCase): self.assertCountEqual(page_params, expected_keys) self.assertIsNone(page_params["state_data"]) + def test_realm_authentication_methods(self) -> None: + realm = get_realm("zulip") + self.login("desdemona") + + with self.settings( + AUTHENTICATION_BACKENDS=( + "zproject.backends.EmailAuthBackend", + "zproject.backends.SAMLAuthBackend", + "zproject.backends.AzureADAuthBackend", + ) + ): + result = self._get_home_page() + state_data = self._get_page_params(result)["state_data"] + + self.assertEqual( + state_data["realm_authentication_methods"], + { + "Email": {"enabled": True, "available": True}, + "AzureAD": { + "enabled": True, + "available": False, + "unavailable_reason": "You need to upgrade to the Zulip Cloud Standard plan to use this authentication method.", + }, + "SAML": { + "enabled": True, + "available": False, + "unavailable_reason": "You need to upgrade to the Zulip Cloud Plus plan to use this authentication method.", + }, + }, + ) + + # Now try with BILLING_ENABLED=False. This simulates a self-hosted deployment + # instead of Zulip Cloud. In this case, all authentication methods should be available. + with self.settings(BILLING_ENABLED=False): + result = self._get_home_page() + state_data = self._get_page_params(result)["state_data"] + + self.assertEqual( + state_data["realm_authentication_methods"], + { + "Email": {"enabled": True, "available": True}, + "AzureAD": { + "enabled": True, + "available": True, + }, + "SAML": { + "enabled": True, + "available": True, + }, + }, + ) + + with self.settings( + AUTHENTICATION_BACKENDS=( + "zproject.backends.EmailAuthBackend", + "zproject.backends.SAMLAuthBackend", + ) + ): + result = self._get_home_page() + state_data = self._get_page_params(result)["state_data"] + + self.assertEqual( + state_data["realm_authentication_methods"], + { + "Email": {"enabled": True, "available": True}, + "SAML": { + "enabled": True, + "available": False, + "unavailable_reason": "You need to upgrade to the Zulip Cloud Plus plan to use this authentication method.", + }, + }, + ) + + # Changing the plan_type to Standard grants access to AzureAD, but not SAML: + do_change_realm_plan_type(realm, Realm.PLAN_TYPE_STANDARD, acting_user=None) + + with self.settings( + AUTHENTICATION_BACKENDS=( + "zproject.backends.EmailAuthBackend", + "zproject.backends.SAMLAuthBackend", + "zproject.backends.AzureADAuthBackend", + ) + ): + result = self._get_home_page() + state_data = self._get_page_params(result)["state_data"] + + self.assertEqual( + state_data["realm_authentication_methods"], + { + "Email": {"enabled": True, "available": True}, + "AzureAD": { + "enabled": True, + "available": True, + }, + "SAML": { + "enabled": True, + "available": False, + "unavailable_reason": "You need to upgrade to the Zulip Cloud Plus plan to use this authentication method.", + }, + }, + ) + + # Now upgrade to Plus and verify that both SAML and AzureAD are available. + do_change_realm_plan_type(realm, Realm.PLAN_TYPE_PLUS, acting_user=None) + result = self._get_home_page() + state_data = self._get_page_params(result)["state_data"] + + self.assertEqual( + state_data["realm_authentication_methods"], + { + "Email": {"enabled": True, "available": True}, + "AzureAD": { + "enabled": True, + "available": True, + }, + "SAML": { + "enabled": True, + "available": True, + }, + }, + ) + def test_sentry_keys(self) -> None: def home_params() -> Dict[str, Any]: result = self._get_home_page() diff --git a/zerver/tests/test_import_export.py b/zerver/tests/test_import_export.py index 9177aeab5f..63e6ad6814 100644 --- a/zerver/tests/test_import_export.py +++ b/zerver/tests/test_import_export.py @@ -1577,6 +1577,52 @@ class RealmImportExportTest(ExportFile): self.assertEqual(message_ids, [555, 888, 999]) + def test_import_of_authentication_methods(self) -> None: + with self.settings( + AUTHENTICATION_BACKENDS=( + "zproject.backends.EmailAuthBackend", + "zproject.backends.AzureADAuthBackend", + "zproject.backends.SAMLAuthBackend", + ) + ): + realm = get_realm("zulip") + authentication_methods_dict = realm.authentication_methods_dict() + for auth_method in authentication_methods_dict: + authentication_methods_dict[auth_method] = True + do_set_realm_authentication_methods( + realm, authentication_methods_dict, acting_user=None + ) + + self.export_realm(realm) + + with self.settings(BILLING_ENABLED=False), self.assertLogs(level="INFO"): + do_import_realm(get_output_dir(), "test-zulip") + + imported_realm = Realm.objects.get(string_id="test-zulip") + self.assertEqual( + realm.authentication_methods_dict(), + imported_realm.authentication_methods_dict(), + ) + + self.export_realm(realm) + + with self.settings(BILLING_ENABLED=True), self.assertLogs(level="WARN") as mock_warn: + do_import_realm(get_output_dir(), "test-zulip2") + + imported_realm = Realm.objects.get(string_id="test-zulip2") + + self.assertEqual( + imported_realm.authentication_methods_dict(), + {"Email": True, "AzureAD": False, "SAML": False}, + ) + self.assertEqual( + mock_warn.output, + [ + "WARNING:root:Dropped restricted authentication method: AzureAD", + "WARNING:root:Dropped restricted authentication method: SAML", + ], + ) + def test_plan_type(self) -> None: user = self.example_user("hamlet") realm = user.realm diff --git a/zerver/tests/test_realm.py b/zerver/tests/test_realm.py index 136253fc67..7cec9b3dc1 100644 --- a/zerver/tests/test_realm.py +++ b/zerver/tests/test_realm.py @@ -30,6 +30,7 @@ from zerver.actions.realm_settings import ( do_reactivate_realm, do_scrub_realm, do_send_realm_reactivation_email, + do_set_realm_authentication_methods, do_set_realm_property, do_set_realm_user_default_setting, ) @@ -851,6 +852,44 @@ class RealmTest(ZulipTestCase): self.assertEqual(get_realm("onpremise").message_visibility_limit, None) self.assertEqual(get_realm("onpremise").upload_quota_gb, None) + def test_initial_auth_methods(self) -> None: + with self.settings( + BILLING_ENABLED=True, + DEVELOPMENT=False, + AUTHENTICATION_BACKENDS=( + "zproject.backends.EmailAuthBackend", + "zproject.backends.AzureADAuthBackend", + "zproject.backends.SAMLAuthBackend", + ), + ): + # Test a Cloud-like realm creation. + # Only the auth backends available on the free plan should be enabled. + realm = do_create_realm("hosted", "hosted") + self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_LIMITED) + + self.assertEqual( + realm.authentication_methods_dict(), + { + "Email": True, + "AzureAD": False, + "SAML": False, + }, + ) + + # Now make sure that a self-hosted server creates realms with all auth methods enabled. + with self.settings(BILLING_ENABLED=False): + realm = do_create_realm("onpremise", "onpremise") + self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_SELF_HOSTED) + + self.assertEqual( + realm.authentication_methods_dict(), + { + "Email": True, + "AzureAD": True, + "SAML": True, + }, + ) + def test_change_org_type(self) -> None: realm = get_realm("zulip") iago = self.example_user("iago") @@ -944,6 +983,38 @@ class RealmTest(ZulipTestCase): self.assertEqual(realm.message_visibility_limit, None) self.assertEqual(realm.upload_quota_gb, None) + @override_settings( + BILLING_ENABLED=True, + AUTHENTICATION_BACKENDS=( + "zproject.backends.EmailAuthBackend", + "zproject.backends.AzureADAuthBackend", + "zproject.backends.SAMLAuthBackend", + ), + ) + def test_realm_authentication_methods_after_downgrade(self) -> None: + realm = get_realm("zulip") + iago = self.example_user("iago") + + do_change_realm_plan_type(realm, Realm.PLAN_TYPE_STANDARD, acting_user=iago) + self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD) + + do_set_realm_authentication_methods( + realm, {"Email": True, "AzureAD": True, "SAML": True}, acting_user=None + ) + + do_change_realm_plan_type(realm, Realm.PLAN_TYPE_LIMITED, acting_user=iago) + realm.refresh_from_db() + self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_LIMITED) + + self.assertEqual( + realm.authentication_methods_dict(), + { + "Email": True, + "AzureAD": False, + "SAML": False, + }, + ) + def test_message_retention_days(self) -> None: self.login("iago") realm = get_realm("zulip") diff --git a/zerver/views/realm.py b/zerver/views/realm.py index 49bdd8e72c..0e368dc1e4 100644 --- a/zerver/views/realm.py +++ b/zerver/views/realm.py @@ -21,6 +21,7 @@ from zerver.actions.realm_settings import ( do_set_realm_zulip_update_announcements_stream, parse_and_set_setting_value_if_required, validate_authentication_methods_dict_from_api, + validate_plan_for_authentication_methods, ) from zerver.decorator import require_realm_admin, require_realm_owner from zerver.forms import check_subdomain_available as check_subdomain @@ -205,6 +206,7 @@ def update_realm( validate_authentication_methods_dict_from_api(realm, authentication_methods) if True not in authentication_methods.values(): raise JsonableError(_("At least one authentication method must be enabled.")) + validate_plan_for_authentication_methods(realm, authentication_methods) if video_chat_provider is not None and video_chat_provider not in { p["id"] for p in Realm.VIDEO_CHAT_PROVIDERS.values() diff --git a/zproject/backends.py b/zproject/backends.py index 00321279d6..46d9c0d86c 100644 --- a/zproject/backends.py +++ b/zproject/backends.py @@ -129,8 +129,23 @@ from zproject.settings_types import OIDCIdPConfigDict redis_client = get_redis_client() -def all_implemented_backend_names() -> List[str]: - return list(AUTH_BACKEND_NAME_MAP.keys()) +def all_default_backend_names() -> List[str]: + if not settings.BILLING_ENABLED or settings.DEVELOPMENT: + # If billing isn't enabled, it's a self-hosted server + # and has access to all authentication backends. + # + # In DEVELOPMENT, we have BILLING_ENABLED=True, but + # nonetheless we want to enable all backends by default + # for convenience - we shouldn't add additional steps to the + # process of setting up a backend for testing. + return list(AUTH_BACKEND_NAME_MAP.keys()) + + # By default, only enable backends that are available without requiring a plan. + return [ + name + for name, backend in AUTH_BACKEND_NAME_MAP.items() + if backend.available_for_cloud_plans is None + ] # This first batch of methods is used by other code in Zulip to check @@ -428,6 +443,11 @@ class ZulipAuthMixin: name = "undefined" _logger: Optional[logging.Logger] = None + # Describes which plans gives access to this authentication method on zulipchat.com. + # None means the backend is available regardless of the plan. + # Otherwise, it should be a list of Realm.plan_type values that give access to the backend. + available_for_cloud_plans: Optional[List[int]] = None + @property def logger(self) -> logging.Logger: if self._logger is None: @@ -2150,6 +2170,12 @@ class AzureADAuthBackend(SocialAuthMixin, AzureADOAuth2): auth_backend_name = "AzureAD" display_icon = staticfiles_storage.url("images/authentication_backends/azuread-icon.png") + available_for_cloud_plans = [ + Realm.PLAN_TYPE_STANDARD, + Realm.PLAN_TYPE_STANDARD_FREE, + Realm.PLAN_TYPE_PLUS, + ] + @external_auth_method class GitLabAuthBackend(SocialAuthMixin, GitLabOAuth2): @@ -2552,6 +2578,8 @@ class SAMLAuthBackend(SocialAuthMixin, SAMLAuth): # provide a registration flow prompt for them to set their name. full_name_validated = True + available_for_cloud_plans = [Realm.PLAN_TYPE_PLUS] + def __init__(self, *args: Any, **kwargs: Any) -> None: if settings.SAML_REQUIRE_LIMIT_TO_SUBDOMAINS: idps_without_limit_to_subdomains = [