mirror of https://github.com/zulip/zulip.git
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:
parent
fdbdf8c620
commit
da9e4e6e54
|
@ -290,7 +290,20 @@ export function dispatch_normal_event(event) {
|
|||
switch (event.property) {
|
||||
case "default":
|
||||
for (const [key, value] of Object.entries(event.data)) {
|
||||
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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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}}
|
||||
</div>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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()
|
||||
]
|
||||
)
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
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.
|
||||
if key == "authentication_methods":
|
||||
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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
]
|
||||
)
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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: |
|
||||
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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -129,9 +129,24 @@ from zproject.settings_types import OIDCIdPConfigDict
|
|||
redis_client = get_redis_client()
|
||||
|
||||
|
||||
def all_implemented_backend_names() -> List[str]:
|
||||
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
|
||||
# whether a given authentication backend is enabled for a given realm.
|
||||
|
@ -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 = [
|
||||
|
|
Loading…
Reference in New Issue