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.
This commit is contained in:
Mateusz Mandera 2024-02-05 23:52:25 +01:00 committed by Tim Abbott
parent fdbdf8c620
commit da9e4e6e54
19 changed files with 538 additions and 38 deletions

View File

@ -290,7 +290,20 @@ export function dispatch_normal_event(event) {
switch (event.property) { switch (event.property) {
case "default": case "default":
for (const [key, value] of Object.entries(event.data)) { 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)) { if (Object.hasOwn(realm_settings, key)) {
settings_org.sync_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(); message_live_update.rerender_messages_view();
} }
} }
if (event.data.authentication_methods !== undefined) {
settings_org.populate_auth_methods(
event.data.authentication_methods,
);
}
break; break;
case "icon": case "icon":
realm.realm_icon_url = event.data.icon_url; realm.realm_icon_url = event.data.icon_url;

View File

@ -88,9 +88,24 @@ export function get_property_value(property_name, for_realm_default_settings, su
return "no_restriction"; return "no_restriction";
} }
if (property_name === "realm_authentication_methods") {
return realm_authentication_methods_to_boolean_dict();
}
return realm[property_name]; 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) { export function extract_property_name($elem, for_realm_default_settings) {
if (for_realm_default_settings) { if (for_realm_default_settings) {
// ID for realm_user_default_settings elements are of the form // ID for realm_user_default_settings elements are of the form

View File

@ -388,18 +388,29 @@ function can_configure_auth_methods() {
return false; return false;
} }
export function populate_auth_methods(auth_methods) { export function populate_auth_methods(auth_method_to_bool_map) {
if (!meta.loaded) { if (!meta.loaded) {
return; return;
} }
const $auth_methods_list = $("#id_realm_authentication_methods").expectOne(); 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 = ""; let rendered_auth_method_rows = "";
for (const [auth_method, value] of Object.entries(auth_methods)) { for (const [auth_method, value] of Object.entries(auth_method_to_bool_map)) {
rendered_auth_method_rows += render_settings_admin_auth_methods_list({ // 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, method: auth_method,
enabled: value, 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 // The negated character class regexp serves as an allowlist - the replace() will
// remove *all* symbols *but* digits (\d) and lowecase letters (a-z), // remove *all* symbols *but* digits (\d) and lowecase letters (a-z),
// so that we can make assumptions on this string elsewhere in the code. // 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 // 1) It contains at least one allowed symbol
// 2) No two auth method names are identical after this allowlist filtering // 2) No two auth method names are identical after this allowlist filtering
prefix: "id_authmethod" + auth_method.toLowerCase().replaceAll(/[^\da-z]/g, "") + "_", 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); $auth_methods_list.html(rendered_auth_method_rows);
} }
@ -967,7 +985,8 @@ export function build_page() {
populate_realm_domains_label(realm.realm_domains); populate_realm_domains_label(realm.realm_domains);
// Populate authentication methods table // 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) { for (const property_name of simple_dropdown_properties) {
settings_components.set_property_dropdown_value(property_name); settings_components.set_property_dropdown_value(property_name);

View File

@ -4,5 +4,6 @@
prefix=prefix prefix=prefix
is_checked=enabled is_checked=enabled
label=method label=method
is_disabled=disable_configure_auth_method}} is_disabled=disable_configure_auth_method
tooltip_text=unavailable_reason}}
</div> </div>

View File

@ -564,13 +564,16 @@ run_test("realm settings", ({override}) => {
realm.realm_allow_message_editing = false; realm.realm_allow_message_editing = false;
realm.realm_message_content_edit_limit_seconds = 0; realm.realm_message_content_edit_limit_seconds = 0;
realm.realm_edit_topic_policy = 3; realm.realm_edit_topic_policy = 3;
realm.realm_authentication_methods = {Google: {enabled: false, available: true}};
override(settings_org, "populate_auth_methods", noop); override(settings_org, "populate_auth_methods", noop);
dispatch(event); dispatch(event);
assert_same(realm.realm_create_multiuse_invite_group, 3); assert_same(realm.realm_create_multiuse_invite_group, 3);
assert_same(realm.realm_allow_message_editing, true); assert_same(realm.realm_allow_message_editing, true);
assert_same(realm.realm_message_content_edit_limit_seconds, 5); assert_same(realm.realm_message_content_edit_limit_seconds, 5);
assert_same(realm.realm_edit_topic_policy, 4); 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; event = event_fixtures.realm__update_dict__icon;
override(realm_icon, "rerender", noop); override(realm_icon, "rerender", noop);

View File

@ -34,7 +34,7 @@ from zerver.models import (
) )
from zerver.models.realms import get_org_type_display_name, get_realm from zerver.models.realms import get_org_type_display_name, get_realm
from zerver.models.users import get_system_bot 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: if settings.CORPORATE_ENABLED:
from corporate.lib.support import get_realm_support_url from corporate.lib.support import get_realm_support_url
@ -261,11 +261,10 @@ def do_create_realm(
create_system_user_groups_for_realm(realm) create_system_user_groups_for_realm(realm)
set_default_for_realm_permission_group_settings(realm) set_default_for_realm_permission_group_settings(realm)
# We create realms with all authentications methods enabled by default.
RealmAuthenticationMethod.objects.bulk_create( RealmAuthenticationMethod.objects.bulk_create(
[ [
RealmAuthenticationMethod(name=backend_name, realm=realm) RealmAuthenticationMethod(name=backend_name, realm=realm)
for backend_name in all_implemented_backend_names() for backend_name in all_default_backend_names()
] ]
) )

View File

@ -210,6 +210,57 @@ def parse_and_set_setting_value_if_required(
return parsed_value, setting_value_changed 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( def validate_authentication_methods_dict_from_api(
realm: Realm, authentication_methods: Dict[str, bool] realm: Realm, authentication_methods: Dict[str, bool]
) -> None: ) -> 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( def do_set_realm_authentication_methods(
realm: Realm, authentication_methods: Dict[str, bool], *, acting_user: Optional[UserProfile] 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( def do_change_realm_plan_type(
realm: Realm, plan_type: int, *, acting_user: Optional[UserProfile] realm: Realm, plan_type: int, *, acting_user: Optional[UserProfile]
) -> None: ) -> None:
from zproject.backends import AUTH_BACKEND_NAME_MAP
old_value = realm.plan_type old_value = realm.plan_type
if plan_type == Realm.PLAN_TYPE_LIMITED: 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 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.plan_type = plan_type
realm.save(update_fields=["plan_type"]) realm.save(update_fields=["plan_type"])
RealmAuditLog.objects.create( RealmAuditLog.objects.create(

View File

@ -41,7 +41,7 @@ from zerver.models import (
Subscription, Subscription,
UserProfile, UserProfile,
) )
from zproject.backends import all_implemented_backend_names from zproject.backends import all_default_backend_names
# stubs # stubs
ZerverFieldsT: TypeAlias = Dict[str, Any] ZerverFieldsT: TypeAlias = Dict[str, Any]
@ -378,7 +378,7 @@ def build_realm(
zerver_realmplayground=[], zerver_realmplayground=[],
zerver_realmauthenticationmethod=[ zerver_realmauthenticationmethod=[
{"realm": realm_id, "name": name, "id": i} {"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 return realm

View File

@ -10,6 +10,7 @@ from django.utils.translation import gettext as _
from version import API_FEATURE_LEVEL, ZULIP_MERGE_BASE, ZULIP_VERSION 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.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.actions.users import get_owned_bot_dicts
from zerver.lib import emoji from zerver.lib import emoji
from zerver.lib.alert_words import user_alert_words 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 # these manual entries are for those realm settings that don't
# fit into that framework. # fit into that framework.
realm_authentication_methods_dict = realm.authentication_methods_dict() 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 # We pretend these features are disabled because anonymous
# users can't access them. In the future, we may want to move # 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": elif event["op"] == "update_dict":
for key, value in event["data"].items(): 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": 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_password_auth_enabled"] = value["Email"] or value["LDAP"]
state["realm_email_auth_enabled"] = value["Email"] state["realm_email_auth_enabled"] = value["Email"]
else:
state["realm_" + key] = value
elif event["op"] == "deactivated": elif event["op"] == "deactivated":
# The realm has just been deactivated. If our request had # The realm has just been deactivated. If our request had
# arrived a moment later, we'd have rendered the # arrived a moment later, we'd have rendered the

View File

@ -81,6 +81,7 @@ from zerver.models.groups import SystemGroups
from zerver.models.realms import get_realm from zerver.models.realms import get_realm
from zerver.models.recipients import get_huddle_hash from zerver.models.recipients import get_huddle_hash
from zerver.models.users import get_system_bot, get_user_profile_by_id from zerver.models.users import get_system_bot, get_user_profile_by_id
from zproject.backends import AUTH_BACKEND_NAME_MAP
realm_tables = [ realm_tables = [
("zerver_realmauthenticationmethod", RealmAuthenticationMethod, "realmauthenticationmethod"), ("zerver_realmauthenticationmethod", RealmAuthenticationMethod, "realmauthenticationmethod"),
@ -897,6 +898,24 @@ def import_uploads(
future.result() 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 # Importing data suffers from a difficult ordering problem because of
# models that reference each other circularly. Here is a correct order. # 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_defaultstream", "stream", related_table="stream")
re_map_foreign_keys(data, "zerver_realmemoji", "author", related_table="user_profile") 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: for table, model, related_table in realm_tables:
re_map_foreign_keys(data, table, "realm", related_table="realm") re_map_foreign_keys(data, table, "realm", related_table="realm")
update_model_ids(model, data, related_table) update_model_ids(model, data, related_table)

View File

@ -14,7 +14,7 @@ from zerver.models import (
) )
from zerver.models.clients import get_client from zerver.models.clients import get_client
from zerver.models.users import get_system_bot 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: def server_initialized() -> bool:
@ -41,11 +41,10 @@ def create_internal_realm() -> None:
create_system_user_groups_for_realm(realm) create_system_user_groups_for_realm(realm)
set_default_for_realm_permission_group_settings(realm) set_default_for_realm_permission_group_settings(realm)
# We create realms with all authentications methods enabled by default.
RealmAuthenticationMethod.objects.bulk_create( RealmAuthenticationMethod.objects.bulk_create(
[ [
RealmAuthenticationMethod(name=backend_name, realm=realm) RealmAuthenticationMethod(name=backend_name, realm=realm)
for backend_name in all_implemented_backend_names() for backend_name in all_default_backend_names()
] ]
) )

View File

@ -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").""" on the server, this will not return an entry for "Email")."""
# This mapping needs to be imported from here due to the cyclic # This mapping needs to be imported from here due to the cyclic
# dependency. # 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] = {} ret: Dict[str, bool] = {}
supported_backends = [type(backend) for backend in supported_auth_backends()] supported_backends = [type(backend) for backend in supported_auth_backends()]
for backend_name in all_implemented_backend_names(): for backend_name, backend_class in AUTH_BACKEND_NAME_MAP.items():
backend_class = AUTH_BACKEND_NAME_MAP[backend_name]
if backend_class in supported_backends: if backend_class in supported_backends:
ret[backend_name] = False ret[backend_name] = False
for realm_authentication_method in RealmAuthenticationMethod.objects.filter( for realm_authentication_method in RealmAuthenticationMethod.objects.filter(

View File

@ -14529,15 +14529,35 @@ paths:
realm_authentication_methods: realm_authentication_methods:
type: object type: object
additionalProperties: 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: | description: |
Boolean describing whether the authentication method (i.e. its key) Dictionary describing the properties of the named authentication method for the
is enabled in this organization. organization - its enabled status and availability for use by the
type: boolean organization.
description: | description: |
Present if `realm` is present in `fetch_event_types`. Present if `realm` is present in `fetch_event_types`.
Dictionary of authentication method keys with boolean values that Dictionary of authentication method keys mapped to dictionaries that
describe whether the named authentication method is enabled for the describe the properties of the named authentication method for the
organization - its enabled status and availability for use by the
organization. organization.
Clients should use this to implement server-settings UI to change which Clients should use this to implement server-settings UI to change which

View File

@ -7200,6 +7200,48 @@ class TestAdminSetBackends(ZulipTestCase):
"Invalid authentication method: NoSuchBackend. Valid methods are: ['Dev', 'Email']", "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): class EmailValidatorTestCase(ZulipTestCase):
def test_valid_email(self) -> None: def test_valid_email(self) -> None:

View File

@ -369,6 +369,128 @@ class HomeTest(ZulipTestCase):
self.assertCountEqual(page_params, expected_keys) self.assertCountEqual(page_params, expected_keys)
self.assertIsNone(page_params["state_data"]) 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 test_sentry_keys(self) -> None:
def home_params() -> Dict[str, Any]: def home_params() -> Dict[str, Any]:
result = self._get_home_page() result = self._get_home_page()

View File

@ -1577,6 +1577,52 @@ class RealmImportExportTest(ExportFile):
self.assertEqual(message_ids, [555, 888, 999]) 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: def test_plan_type(self) -> None:
user = self.example_user("hamlet") user = self.example_user("hamlet")
realm = user.realm realm = user.realm

View File

@ -30,6 +30,7 @@ from zerver.actions.realm_settings import (
do_reactivate_realm, do_reactivate_realm,
do_scrub_realm, do_scrub_realm,
do_send_realm_reactivation_email, do_send_realm_reactivation_email,
do_set_realm_authentication_methods,
do_set_realm_property, do_set_realm_property,
do_set_realm_user_default_setting, 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").message_visibility_limit, None)
self.assertEqual(get_realm("onpremise").upload_quota_gb, 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: def test_change_org_type(self) -> None:
realm = get_realm("zulip") realm = get_realm("zulip")
iago = self.example_user("iago") iago = self.example_user("iago")
@ -944,6 +983,38 @@ class RealmTest(ZulipTestCase):
self.assertEqual(realm.message_visibility_limit, None) self.assertEqual(realm.message_visibility_limit, None)
self.assertEqual(realm.upload_quota_gb, 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: def test_message_retention_days(self) -> None:
self.login("iago") self.login("iago")
realm = get_realm("zulip") realm = get_realm("zulip")

View File

@ -21,6 +21,7 @@ from zerver.actions.realm_settings import (
do_set_realm_zulip_update_announcements_stream, do_set_realm_zulip_update_announcements_stream,
parse_and_set_setting_value_if_required, parse_and_set_setting_value_if_required,
validate_authentication_methods_dict_from_api, validate_authentication_methods_dict_from_api,
validate_plan_for_authentication_methods,
) )
from zerver.decorator import require_realm_admin, require_realm_owner from zerver.decorator import require_realm_admin, require_realm_owner
from zerver.forms import check_subdomain_available as check_subdomain 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) validate_authentication_methods_dict_from_api(realm, authentication_methods)
if True not in authentication_methods.values(): if True not in authentication_methods.values():
raise JsonableError(_("At least one authentication method must be enabled.")) 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 { if video_chat_provider is not None and video_chat_provider not in {
p["id"] for p in Realm.VIDEO_CHAT_PROVIDERS.values() p["id"] for p in Realm.VIDEO_CHAT_PROVIDERS.values()

View File

@ -129,8 +129,23 @@ from zproject.settings_types import OIDCIdPConfigDict
redis_client = get_redis_client() redis_client = get_redis_client()
def all_implemented_backend_names() -> List[str]: def all_default_backend_names() -> List[str]:
return list(AUTH_BACKEND_NAME_MAP.keys()) 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 # This first batch of methods is used by other code in Zulip to check
@ -428,6 +443,11 @@ class ZulipAuthMixin:
name = "undefined" name = "undefined"
_logger: Optional[logging.Logger] = None _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 @property
def logger(self) -> logging.Logger: def logger(self) -> logging.Logger:
if self._logger is None: if self._logger is None:
@ -2150,6 +2170,12 @@ class AzureADAuthBackend(SocialAuthMixin, AzureADOAuth2):
auth_backend_name = "AzureAD" auth_backend_name = "AzureAD"
display_icon = staticfiles_storage.url("images/authentication_backends/azuread-icon.png") 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 @external_auth_method
class GitLabAuthBackend(SocialAuthMixin, GitLabOAuth2): class GitLabAuthBackend(SocialAuthMixin, GitLabOAuth2):
@ -2552,6 +2578,8 @@ class SAMLAuthBackend(SocialAuthMixin, SAMLAuth):
# provide a registration flow prompt for them to set their name. # provide a registration flow prompt for them to set their name.
full_name_validated = True full_name_validated = True
available_for_cloud_plans = [Realm.PLAN_TYPE_PLUS]
def __init__(self, *args: Any, **kwargs: Any) -> None: def __init__(self, *args: Any, **kwargs: Any) -> None:
if settings.SAML_REQUIRE_LIMIT_TO_SUBDOMAINS: if settings.SAML_REQUIRE_LIMIT_TO_SUBDOMAINS:
idps_without_limit_to_subdomains = [ idps_without_limit_to_subdomains = [