settings: Add permission to enforce unique names in realm.

Previously, users were allowed to signup or change their names to
those which already existed in the realm.

This commit adds an Organization Permission, that shall enforce
users to use unique names while signing up or changing their
names. If a same or normalized full name is found in realm,
then a validation error is thrown.

Fixes #7830.
This commit is contained in:
roanster007 2024-03-12 00:32:05 +05:30 committed by Tim Abbott
parent a283a19c9f
commit c7a08f3b77
20 changed files with 188 additions and 13 deletions

View File

@ -20,6 +20,12 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 9.0 ## Changes in Zulip 9.0
**Feature level 246**
* [`POST /register`](/api/register-queue), [`POST
/events`](/api/get-events): Added new `require_unique_names` setting
controlling whether users names can duplicate others.
**Feature level 245** **Feature level 245**
* [`PATCH * [`PATCH

View File

@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.9.3"
# Changes should be accompanied by documentation explaining what the # Changes should be accompanied by documentation explaining what the
# new level means in api_docs/changelog.md, as well as "**Changes**" # new level means in api_docs/changelog.md, as well as "**Changes**"
# entries in the endpoint's documentation in `zulip.yaml`. # entries in the endpoint's documentation in `zulip.yaml`.
API_FEATURE_LEVEL = 245 API_FEATURE_LEVEL = 246
# Bump the minor PROVISION_VERSION to indicate that folks should provision # Bump the minor PROVISION_VERSION to indicate that folks should provision
# only when going from an old version of the code to a newer version. Bump # only when going from an old version of the code to a newer version. Bump

View File

@ -50,6 +50,7 @@ const admin_settings_label = {
realm_default_code_block_language: $t({defaultMessage: "Default language for code blocks"}), realm_default_code_block_language: $t({defaultMessage: "Default language for code blocks"}),
// Organization permissions // Organization permissions
realm_require_unique_names: $t({defaultMessage: "Require unique names"}),
realm_name_changes_disabled: $t({defaultMessage: "Prevent users from changing their name"}), realm_name_changes_disabled: $t({defaultMessage: "Prevent users from changing their name"}),
realm_email_changes_disabled: $t({ realm_email_changes_disabled: $t({
defaultMessage: "Prevent users from changing their email address", defaultMessage: "Prevent users from changing their email address",
@ -118,6 +119,7 @@ export function build_page() {
server_inline_url_embed_preview: realm.server_inline_url_embed_preview, server_inline_url_embed_preview: realm.server_inline_url_embed_preview,
realm_authentication_methods: realm.realm_authentication_methods, realm_authentication_methods: realm.realm_authentication_methods,
realm_name_changes_disabled: realm.realm_name_changes_disabled, realm_name_changes_disabled: realm.realm_name_changes_disabled,
realm_require_unique_names: realm.realm_require_unique_names,
realm_email_changes_disabled: realm.realm_email_changes_disabled, realm_email_changes_disabled: realm.realm_email_changes_disabled,
realm_avatar_changes_disabled: realm.realm_avatar_changes_disabled, realm_avatar_changes_disabled: realm.realm_avatar_changes_disabled,
realm_add_custom_emoji_policy: realm.realm_add_custom_emoji_policy, realm_add_custom_emoji_policy: realm.realm_add_custom_emoji_policy,

View File

@ -239,6 +239,7 @@ export function dispatch_normal_event(event) {
org_type: noop, org_type: noop,
private_message_policy: compose_recipient.check_posting_policy_for_compose_box, private_message_policy: compose_recipient.check_posting_policy_for_compose_box,
push_notifications_enabled: noop, push_notifications_enabled: noop,
require_unique_names: noop,
send_welcome_emails: noop, send_welcome_emails: noop,
message_content_allowed_in_email_notifications: noop, message_content_allowed_in_email_notifications: noop,
enable_spectator_access: noop, enable_spectator_access: noop,

View File

@ -179,6 +179,7 @@ export const realm_schema = z.object({
realm_presence_disabled: z.boolean(), realm_presence_disabled: z.boolean(),
realm_private_message_policy: z.number(), realm_private_message_policy: z.number(),
realm_push_notifications_enabled: z.boolean(), realm_push_notifications_enabled: z.boolean(),
realm_require_unique_names: z.boolean(),
realm_signup_announcements_stream_id: z.number(), realm_signup_announcements_stream_id: z.number(),
realm_upload_quota_mib: z.nullable(z.number()), realm_upload_quota_mib: z.nullable(z.number()),
realm_uri: z.string(), realm_uri: z.string(),

View File

@ -611,6 +611,7 @@ export function initialize_everything(state_data) {
"realm_private_message_policy", "realm_private_message_policy",
"realm_push_notifications_enabled", "realm_push_notifications_enabled",
"realm_push_notifications_enabled_end_timestamp", "realm_push_notifications_enabled_end_timestamp",
"realm_require_unique_names",
"realm_send_welcome_emails", "realm_send_welcome_emails",
"realm_signup_announcements_stream_id", "realm_signup_announcements_stream_id",
"realm_upload_quota_mib", "realm_upload_quota_mib",

View File

@ -261,6 +261,12 @@
{{> settings_save_discard_widget section_name="user-identity" }} {{> settings_save_discard_widget section_name="user-identity" }}
</div> </div>
<div class="inline-block organization-permissions-parent"> <div class="inline-block organization-permissions-parent">
{{> settings_checkbox
setting_name="realm_require_unique_names"
prefix="id_"
is_checked=realm_require_unique_names
label=admin_settings_label.realm_require_unique_names}}
{{> settings_checkbox {{> settings_checkbox
setting_name="realm_name_changes_disabled" setting_name="realm_name_changes_disabled"
prefix="id_" prefix="id_"

View File

@ -249,7 +249,9 @@ def check_change_full_name(
is responsible for checking check permissions. Returns the new is responsible for checking check permissions. Returns the new
full name, which may differ from what was passed in (because this full name, which may differ from what was passed in (because this
function strips whitespace).""" function strips whitespace)."""
new_full_name = check_full_name(full_name_raw) new_full_name = check_full_name(
full_name_raw=full_name_raw, user_profile=user_profile, realm=user_profile.realm
)
do_change_full_name(user_profile, new_full_name, acting_user) do_change_full_name(user_profile, new_full_name, acting_user)
return new_full_name return new_full_name
@ -257,7 +259,9 @@ def check_change_full_name(
def check_change_bot_full_name( def check_change_bot_full_name(
user_profile: UserProfile, full_name_raw: str, acting_user: UserProfile user_profile: UserProfile, full_name_raw: str, acting_user: UserProfile
) -> None: ) -> None:
new_full_name = check_full_name(full_name_raw) new_full_name = check_full_name(
full_name_raw=full_name_raw, user_profile=user_profile, realm=user_profile.realm
)
if new_full_name == user_profile.full_name: if new_full_name == user_profile.full_name:
# Our web app will try to patch full_name even if the user didn't # Our web app will try to patch full_name even if the user didn't

View File

@ -180,6 +180,11 @@ class RegistrationForm(RealmDetailsForm):
) )
def __init__(self, *args: Any, **kwargs: Any) -> None: def __init__(self, *args: Any, **kwargs: Any) -> None:
# Since the superclass doesn't except random extra kwargs, we
# remove it from the kwargs dict before initializing.
self.realm_creation = kwargs["realm_creation"]
self.realm = kwargs.pop("realm", None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if settings.TERMS_OF_SERVICE_VERSION is not None: if settings.TERMS_OF_SERVICE_VERSION is not None:
self.fields["terms"] = forms.BooleanField(required=True) self.fields["terms"] = forms.BooleanField(required=True)
@ -211,7 +216,9 @@ class RegistrationForm(RealmDetailsForm):
def clean_full_name(self) -> str: def clean_full_name(self) -> str:
try: try:
return check_full_name(self.cleaned_data["full_name"]) return check_full_name(
full_name_raw=self.cleaned_data["full_name"], user_profile=None, realm=self.realm
)
except JsonableError as e: except JsonableError as e:
raise ValidationError(e.msg) raise ValidationError(e.msg)

View File

@ -38,7 +38,7 @@ from zerver.models import (
UserProfile, UserProfile,
) )
from zerver.models.groups import SystemGroups from zerver.models.groups import SystemGroups
from zerver.models.realms import get_fake_email_domain from zerver.models.realms import get_fake_email_domain, require_unique_names
from zerver.models.users import ( from zerver.models.users import (
active_non_guest_user_ids, active_non_guest_user_ids,
active_user_ids, active_user_ids,
@ -50,7 +50,9 @@ from zerver.models.users import (
) )
def check_full_name(full_name_raw: str) -> str: def check_full_name(
full_name_raw: str, *, user_profile: Optional[UserProfile], realm: Optional[Realm]
) -> str:
full_name = full_name_raw.strip() full_name = full_name_raw.strip()
if len(full_name) > UserProfile.MAX_NAME_LENGTH: if len(full_name) > UserProfile.MAX_NAME_LENGTH:
raise JsonableError(_("Name too long!")) raise JsonableError(_("Name too long!"))
@ -65,6 +67,26 @@ def check_full_name(full_name_raw: str) -> str:
# ban them. # ban them.
if re.search(r"\|\d+$", full_name_raw): if re.search(r"\|\d+$", full_name_raw):
raise JsonableError(_("Invalid format!")) raise JsonableError(_("Invalid format!"))
if require_unique_names(realm):
normalized_user_full_name = unicodedata.normalize("NFKC", full_name).casefold()
users_query = UserProfile.objects.filter(realm=realm)
# We want to exclude the user's full name while checking for
# uniqueness.
if user_profile is not None:
existing_names = users_query.exclude(id=user_profile.id).values_list(
"full_name", flat=True
)
else:
existing_names = users_query.values_list("full_name", flat=True)
normalized_existing_names = [
unicodedata.normalize("NFKC", full_name).casefold() for full_name in existing_names
]
if normalized_user_full_name in normalized_existing_names:
raise JsonableError(_("Unique names required in this organization."))
return full_name return full_name

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.11 on 2024-03-27 20:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zerver", "0505_realmuserdefault_web_font_size_px_and_more"),
]
operations = [
migrations.AddField(
model_name="realm",
name="require_unique_names",
field=models.BooleanField(default=False),
),
]

View File

@ -167,6 +167,7 @@ class Realm(models.Model): # type: ignore[django-manager-missing] # django-stub
mandatory_topics = models.BooleanField(default=False) mandatory_topics = models.BooleanField(default=False)
require_unique_names = models.BooleanField(default=False)
name_changes_disabled = models.BooleanField(default=False) name_changes_disabled = models.BooleanField(default=False)
email_changes_disabled = models.BooleanField(default=False) email_changes_disabled = models.BooleanField(default=False)
avatar_changes_disabled = models.BooleanField(default=False) avatar_changes_disabled = models.BooleanField(default=False)
@ -627,6 +628,7 @@ class Realm(models.Model): # type: ignore[django-manager-missing] # django-stub
name_changes_disabled=bool, name_changes_disabled=bool,
private_message_policy=int, private_message_policy=int,
push_notifications_enabled=bool, push_notifications_enabled=bool,
require_unique_names=bool,
send_welcome_emails=bool, send_welcome_emails=bool,
user_group_edit_policy=int, user_group_edit_policy=int,
video_chat_provider=int, video_chat_provider=int,
@ -964,6 +966,13 @@ def get_realm_by_id(realm_id: int) -> Realm:
return Realm.objects.get(id=realm_id) return Realm.objects.get(id=realm_id)
def require_unique_names(realm: Optional[Realm]) -> bool:
if realm is None:
# realm is None when a new realm is being created.
return False
return realm.require_unique_names
def name_changes_disabled(realm: Optional[Realm]) -> bool: def name_changes_disabled(realm: Optional[Realm]) -> bool:
if realm is None: if realm is None:
return settings.NAME_CHANGES_DISABLED return settings.NAME_CHANGES_DISABLED

View File

@ -4540,6 +4540,17 @@ paths:
- 2 = Nobody - 2 = Nobody
**Changes**: New in Zulip 3.0 (feature level 1). **Changes**: New in Zulip 3.0 (feature level 1).
require_unique_names:
type: boolean
description: |
Indicates whether the organization is configured to require users to have
unique full names. If true, the server will reject attempts to create a
new user, or change the name of an existing user, where doing so would
lead to two users whose names are identical modulo case and unicode
normalization.
**Changes**: New in Zulip 9.0 (feature level 246). Previously, the Zulip
server could not be configured to enforce unique names.
send_welcome_emails: send_welcome_emails:
type: boolean type: boolean
description: | description: |
@ -14640,6 +14651,17 @@ paths:
Present if `realm` is present in `fetch_event_types`. Present if `realm` is present in `fetch_event_types`.
The name of the organization, used in login pages etc. The name of the organization, used in login pages etc.
realm_require_unique_names:
type: boolean
description: |
Indicates whether the organization is configured to require users
to have unique full names. If true, the server will reject attempts
to create a new user, or change the name of an existing user, where
doing so would lead to two users whose names are identical modulo
case and unicode normalization.
**Changes**: New in Zulip 9.0 (feature level 246). Previously, the Zulip
server could not be configured to enforce unique names.
realm_name_changes_disabled: realm_name_changes_disabled:
type: boolean type: boolean
description: | description: |

View File

@ -187,6 +187,7 @@ class HomeTest(ZulipTestCase):
"realm_private_message_policy", "realm_private_message_policy",
"realm_push_notifications_enabled", "realm_push_notifications_enabled",
"realm_push_notifications_enabled_end_timestamp", "realm_push_notifications_enabled_end_timestamp",
"realm_require_unique_names",
"realm_send_welcome_emails", "realm_send_welcome_emails",
"realm_signup_announcements_stream_id", "realm_signup_announcements_stream_id",
"realm_upload_quota_mib", "realm_upload_quota_mib",

View File

@ -2421,6 +2421,25 @@ class UserSignUpTest(ZulipTestCase):
# Verify that the user is asked for name and password # Verify that the user is asked for name and password
self.assert_in_success_response(["id_password", "id_full_name"], result) self.assert_in_success_response(["id_password", "id_full_name"], result)
def test_signup_with_existing_name(self) -> None:
"""
Check if signing up with an existing name when organization
has set "Require Unique Names"is handled properly.
"""
iago = self.example_user("iago")
email = "newguy@zulip.com"
password = "newpassword"
do_set_realm_property(iago.realm, "require_unique_names", True, acting_user=None)
result = self.verify_signup(email=email, password=password, full_name="IaGo")
assert not isinstance(result, UserProfile)
self.assert_in_success_response(["Unique names required in this organization."], result)
do_set_realm_property(iago.realm, "require_unique_names", False, acting_user=None)
result = self.verify_signup(email=email, password=password, full_name="IaGo")
assert isinstance(result, UserProfile)
def test_signup_without_password(self) -> None: def test_signup_without_password(self) -> None:
""" """
Check if signing up without a password works properly when Check if signing up without a password works properly when

View File

@ -414,6 +414,45 @@ class PermissionTest(ZulipTestCase):
result = self.client_patch("/json/users/{}".format(self.example_user("hamlet").id), req) result = self.client_patch("/json/users/{}".format(self.example_user("hamlet").id), req)
self.assert_json_success(result) self.assert_json_success(result)
def test_require_unique_names(self) -> None:
self.login("desdemona")
iago = self.example_user("iago")
hamlet = self.example_user("hamlet")
do_set_realm_property(hamlet.realm, "require_unique_names", True, acting_user=None)
req = dict(full_name="IaGo")
result = self.client_patch(f"/json/users/{hamlet.id}", req)
self.assert_json_error(result, "Unique names required in this organization.")
req = dict(full_name="𝕚𝕒𝕘𝕠")
result = self.client_patch(f"/json/users/{hamlet.id}", req)
self.assert_json_error(result, "Unique names required in this organization.")
req = dict(full_name="")
result = self.client_patch(f"/json/users/{hamlet.id}", req)
self.assert_json_error(result, "Unique names required in this organization.")
req = dict(full_name="𝒾𝒶𝑔𝑜")
result = self.client_patch(f"/json/users/{hamlet.id}", req)
self.assert_json_error(result, "Unique names required in this organization.")
# check for uniqueness including imported users
iago.is_mirror_dummy = True
req = dict(full_name="iago")
result = self.client_patch(f"/json/users/{hamlet.id}", req)
self.assert_json_error(result, "Unique names required in this organization.")
# check for uniqueness including deactivated users
do_deactivate_user(iago, acting_user=None)
req = dict(full_name="iago")
result = self.client_patch(f"/json/users/{hamlet.id}", req)
self.assert_json_error(result, "Unique names required in this organization.")
do_set_realm_property(hamlet.realm, "require_unique_names", False, acting_user=None)
req = dict(full_name="iago")
result = self.client_patch(f"/json/users/{hamlet.id}", req)
self.assert_json_success(result)
def test_not_allowed_format_complex(self) -> None: def test_not_allowed_format_complex(self) -> None:
new_name = "Hello- 12iago|72" new_name = "Hello- 12iago|72"
self.login("iago") self.login("iago")

View File

@ -82,6 +82,7 @@ def update_realm(
create_multiuse_invite_group_id: Optional[int] = REQ( create_multiuse_invite_group_id: Optional[int] = REQ(
"create_multiuse_invite_group", json_validator=check_int, default=None "create_multiuse_invite_group", json_validator=check_int, default=None
), ),
require_unique_names: Optional[bool] = REQ(json_validator=check_bool, default=None),
name_changes_disabled: Optional[bool] = REQ(json_validator=check_bool, default=None), name_changes_disabled: Optional[bool] = REQ(json_validator=check_bool, default=None),
email_changes_disabled: Optional[bool] = REQ(json_validator=check_bool, default=None), email_changes_disabled: Optional[bool] = REQ(json_validator=check_bool, default=None),
avatar_changes_disabled: Optional[bool] = REQ(json_validator=check_bool, default=None), avatar_changes_disabled: Optional[bool] = REQ(json_validator=check_bool, default=None),

View File

@ -394,7 +394,10 @@ def registration_helper(
# so they can be directly registered without having to go through # so they can be directly registered without having to go through
# this interstitial. # this interstitial.
form = RegistrationForm( form = RegistrationForm(
{"full_name": ldap_full_name}, initial=initial_data, realm_creation=realm_creation {"full_name": ldap_full_name},
initial=initial_data,
realm_creation=realm_creation,
realm=realm,
) )
request.session["authenticated_full_name"] = ldap_full_name request.session["authenticated_full_name"] = ldap_full_name
name_validated = True name_validated = True
@ -407,6 +410,7 @@ def registration_helper(
form = RegistrationForm( form = RegistrationForm(
initial={"full_name": hesiod_name if "@" not in hesiod_name else ""}, initial={"full_name": hesiod_name if "@" not in hesiod_name else ""},
realm_creation=realm_creation, realm_creation=realm_creation,
realm=realm,
) )
name_validated = True name_validated = True
elif prereg_user is not None and prereg_user.full_name: elif prereg_user is not None and prereg_user.full_name:
@ -417,21 +421,26 @@ def registration_helper(
{"full_name": prereg_user.full_name}, {"full_name": prereg_user.full_name},
initial=initial_data, initial=initial_data,
realm_creation=realm_creation, realm_creation=realm_creation,
realm=realm,
) )
else: else:
initial_data["full_name"] = prereg_user.full_name initial_data["full_name"] = prereg_user.full_name
form = RegistrationForm( form = RegistrationForm(
initial=initial_data, initial=initial_data,
realm_creation=realm_creation, realm_creation=realm_creation,
realm=realm,
) )
elif form_full_name is not None: elif form_full_name is not None:
initial_data["full_name"] = form_full_name initial_data["full_name"] = form_full_name
form = RegistrationForm( form = RegistrationForm(
initial=initial_data, initial=initial_data,
realm_creation=realm_creation, realm_creation=realm_creation,
realm=realm,
) )
else: else:
form = RegistrationForm(initial=initial_data, realm_creation=realm_creation) form = RegistrationForm(
initial=initial_data, realm_creation=realm_creation, realm=realm
)
else: else:
postdata = request.POST.copy() postdata = request.POST.copy()
if name_changes_disabled(realm): if name_changes_disabled(realm):
@ -443,7 +452,7 @@ def registration_helper(
name_validated = True name_validated = True
except KeyError: except KeyError:
pass pass
form = RegistrationForm(postdata, realm_creation=realm_creation) form = RegistrationForm(postdata, realm_creation=realm_creation, realm=realm)
if not (password_auth_enabled(realm) and password_required): if not (password_auth_enabled(realm) and password_required):
form["password"].field.required = False form["password"].field.required = False

View File

@ -490,7 +490,9 @@ def add_bot_backend(
if bot_type != UserProfile.INCOMING_WEBHOOK_BOT: if bot_type != UserProfile.INCOMING_WEBHOOK_BOT:
service_name = service_name or short_name service_name = service_name or short_name
short_name += "-bot" short_name += "-bot"
full_name = check_full_name(full_name_raw) full_name = check_full_name(
full_name_raw=full_name_raw, user_profile=user_profile, realm=user_profile.realm
)
try: try:
email = Address(username=short_name, domain=user_profile.realm.get_bot_domain()).addr_spec email = Address(username=short_name, domain=user_profile.realm.get_bot_domain()).addr_spec
except InvalidFakeEmailDomainError: except InvalidFakeEmailDomainError:
@ -695,7 +697,9 @@ def create_user_backend(
if not user_profile.can_create_users: if not user_profile.can_create_users:
raise JsonableError(_("User not authorized to create users")) raise JsonableError(_("User not authorized to create users"))
full_name = check_full_name(full_name_raw) full_name = check_full_name(
full_name_raw=full_name_raw, user_profile=user_profile, realm=user_profile.realm
)
form = CreateUserForm({"full_name": full_name, "email": email}) form = CreateUserForm({"full_name": full_name, "email": email})
if not form.is_valid(): if not form.is_valid():
raise JsonableError(_("Bad name or username")) raise JsonableError(_("Bad name or username"))

View File

@ -930,7 +930,9 @@ class ZulipLDAPAuthBackendBase(ZulipAuthMixin, LDAPBackend):
full_name = self.get_mapped_name(ldap_user) full_name = self.get_mapped_name(ldap_user)
if full_name != user_profile.full_name: if full_name != user_profile.full_name:
try: try:
full_name = check_full_name(full_name) full_name = check_full_name(
full_name_raw=full_name, user_profile=user_profile, realm=user_profile.realm
)
except JsonableError as e: except JsonableError as e:
raise ZulipLDAPError(e.msg) raise ZulipLDAPError(e.msg)
do_change_full_name(user_profile, full_name, None) do_change_full_name(user_profile, full_name, None)
@ -1148,7 +1150,9 @@ class ZulipLDAPAuthBackend(ZulipLDAPAuthBackendBase):
# We have valid LDAP credentials; time to create an account. # We have valid LDAP credentials; time to create an account.
full_name = self.get_mapped_name(ldap_user) full_name = self.get_mapped_name(ldap_user)
try: try:
full_name = check_full_name(full_name) full_name = check_full_name(
full_name_raw=full_name, user_profile=None, realm=self._realm
)
except JsonableError as e: except JsonableError as e:
raise ZulipLDAPError(e.msg) raise ZulipLDAPError(e.msg)