mirror of https://github.com/zulip/zulip.git
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:
parent
a283a19c9f
commit
c7a08f3b77
|
@ -20,6 +20,12 @@ format used by the Zulip server that they are interacting with.
|
|||
|
||||
## 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**
|
||||
|
||||
* [`PATCH
|
||||
|
|
|
@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.9.3"
|
|||
# Changes should be accompanied by documentation explaining what the
|
||||
# new level means in api_docs/changelog.md, as well as "**Changes**"
|
||||
# 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
|
||||
# only when going from an old version of the code to a newer version. Bump
|
||||
|
|
|
@ -50,6 +50,7 @@ const admin_settings_label = {
|
|||
realm_default_code_block_language: $t({defaultMessage: "Default language for code blocks"}),
|
||||
|
||||
// Organization permissions
|
||||
realm_require_unique_names: $t({defaultMessage: "Require unique names"}),
|
||||
realm_name_changes_disabled: $t({defaultMessage: "Prevent users from changing their name"}),
|
||||
realm_email_changes_disabled: $t({
|
||||
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,
|
||||
realm_authentication_methods: realm.realm_authentication_methods,
|
||||
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_avatar_changes_disabled: realm.realm_avatar_changes_disabled,
|
||||
realm_add_custom_emoji_policy: realm.realm_add_custom_emoji_policy,
|
||||
|
|
|
@ -239,6 +239,7 @@ export function dispatch_normal_event(event) {
|
|||
org_type: noop,
|
||||
private_message_policy: compose_recipient.check_posting_policy_for_compose_box,
|
||||
push_notifications_enabled: noop,
|
||||
require_unique_names: noop,
|
||||
send_welcome_emails: noop,
|
||||
message_content_allowed_in_email_notifications: noop,
|
||||
enable_spectator_access: noop,
|
||||
|
|
|
@ -179,6 +179,7 @@ export const realm_schema = z.object({
|
|||
realm_presence_disabled: z.boolean(),
|
||||
realm_private_message_policy: z.number(),
|
||||
realm_push_notifications_enabled: z.boolean(),
|
||||
realm_require_unique_names: z.boolean(),
|
||||
realm_signup_announcements_stream_id: z.number(),
|
||||
realm_upload_quota_mib: z.nullable(z.number()),
|
||||
realm_uri: z.string(),
|
||||
|
|
|
@ -611,6 +611,7 @@ export function initialize_everything(state_data) {
|
|||
"realm_private_message_policy",
|
||||
"realm_push_notifications_enabled",
|
||||
"realm_push_notifications_enabled_end_timestamp",
|
||||
"realm_require_unique_names",
|
||||
"realm_send_welcome_emails",
|
||||
"realm_signup_announcements_stream_id",
|
||||
"realm_upload_quota_mib",
|
||||
|
|
|
@ -261,6 +261,12 @@
|
|||
{{> settings_save_discard_widget section_name="user-identity" }}
|
||||
</div>
|
||||
<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
|
||||
setting_name="realm_name_changes_disabled"
|
||||
prefix="id_"
|
||||
|
|
|
@ -249,7 +249,9 @@ def check_change_full_name(
|
|||
is responsible for checking check permissions. Returns the new
|
||||
full name, which may differ from what was passed in (because this
|
||||
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)
|
||||
return new_full_name
|
||||
|
||||
|
@ -257,7 +259,9 @@ def check_change_full_name(
|
|||
def check_change_bot_full_name(
|
||||
user_profile: UserProfile, full_name_raw: str, acting_user: UserProfile
|
||||
) -> 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:
|
||||
# Our web app will try to patch full_name even if the user didn't
|
||||
|
|
|
@ -180,6 +180,11 @@ class RegistrationForm(RealmDetailsForm):
|
|||
)
|
||||
|
||||
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)
|
||||
if settings.TERMS_OF_SERVICE_VERSION is not None:
|
||||
self.fields["terms"] = forms.BooleanField(required=True)
|
||||
|
@ -211,7 +216,9 @@ class RegistrationForm(RealmDetailsForm):
|
|||
|
||||
def clean_full_name(self) -> str:
|
||||
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:
|
||||
raise ValidationError(e.msg)
|
||||
|
||||
|
|
|
@ -38,7 +38,7 @@ from zerver.models import (
|
|||
UserProfile,
|
||||
)
|
||||
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 (
|
||||
active_non_guest_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()
|
||||
if len(full_name) > UserProfile.MAX_NAME_LENGTH:
|
||||
raise JsonableError(_("Name too long!"))
|
||||
|
@ -65,6 +67,26 @@ def check_full_name(full_name_raw: str) -> str:
|
|||
# ban them.
|
||||
if re.search(r"\|\d+$", full_name_raw):
|
||||
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
|
||||
|
||||
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -167,6 +167,7 @@ class Realm(models.Model): # type: ignore[django-manager-missing] # django-stub
|
|||
|
||||
mandatory_topics = models.BooleanField(default=False)
|
||||
|
||||
require_unique_names = models.BooleanField(default=False)
|
||||
name_changes_disabled = models.BooleanField(default=False)
|
||||
email_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,
|
||||
private_message_policy=int,
|
||||
push_notifications_enabled=bool,
|
||||
require_unique_names=bool,
|
||||
send_welcome_emails=bool,
|
||||
user_group_edit_policy=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)
|
||||
|
||||
|
||||
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:
|
||||
if realm is None:
|
||||
return settings.NAME_CHANGES_DISABLED
|
||||
|
|
|
@ -4540,6 +4540,17 @@ paths:
|
|||
- 2 = Nobody
|
||||
|
||||
**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:
|
||||
type: boolean
|
||||
description: |
|
||||
|
@ -14640,6 +14651,17 @@ paths:
|
|||
Present if `realm` is present in `fetch_event_types`.
|
||||
|
||||
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:
|
||||
type: boolean
|
||||
description: |
|
||||
|
|
|
@ -187,6 +187,7 @@ class HomeTest(ZulipTestCase):
|
|||
"realm_private_message_policy",
|
||||
"realm_push_notifications_enabled",
|
||||
"realm_push_notifications_enabled_end_timestamp",
|
||||
"realm_require_unique_names",
|
||||
"realm_send_welcome_emails",
|
||||
"realm_signup_announcements_stream_id",
|
||||
"realm_upload_quota_mib",
|
||||
|
|
|
@ -2421,6 +2421,25 @@ class UserSignUpTest(ZulipTestCase):
|
|||
# Verify that the user is asked for name and password
|
||||
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:
|
||||
"""
|
||||
Check if signing up without a password works properly when
|
||||
|
|
|
@ -414,6 +414,45 @@ class PermissionTest(ZulipTestCase):
|
|||
result = self.client_patch("/json/users/{}".format(self.example_user("hamlet").id), req)
|
||||
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="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.")
|
||||
|
||||
# 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:
|
||||
new_name = "Hello- 12iago|72"
|
||||
self.login("iago")
|
||||
|
|
|
@ -82,6 +82,7 @@ def update_realm(
|
|||
create_multiuse_invite_group_id: Optional[int] = REQ(
|
||||
"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),
|
||||
email_changes_disabled: Optional[bool] = REQ(json_validator=check_bool, default=None),
|
||||
avatar_changes_disabled: Optional[bool] = REQ(json_validator=check_bool, default=None),
|
||||
|
|
|
@ -394,7 +394,10 @@ def registration_helper(
|
|||
# so they can be directly registered without having to go through
|
||||
# this interstitial.
|
||||
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
|
||||
name_validated = True
|
||||
|
@ -407,6 +410,7 @@ def registration_helper(
|
|||
form = RegistrationForm(
|
||||
initial={"full_name": hesiod_name if "@" not in hesiod_name else ""},
|
||||
realm_creation=realm_creation,
|
||||
realm=realm,
|
||||
)
|
||||
name_validated = True
|
||||
elif prereg_user is not None and prereg_user.full_name:
|
||||
|
@ -417,21 +421,26 @@ def registration_helper(
|
|||
{"full_name": prereg_user.full_name},
|
||||
initial=initial_data,
|
||||
realm_creation=realm_creation,
|
||||
realm=realm,
|
||||
)
|
||||
else:
|
||||
initial_data["full_name"] = prereg_user.full_name
|
||||
form = RegistrationForm(
|
||||
initial=initial_data,
|
||||
realm_creation=realm_creation,
|
||||
realm=realm,
|
||||
)
|
||||
elif form_full_name is not None:
|
||||
initial_data["full_name"] = form_full_name
|
||||
form = RegistrationForm(
|
||||
initial=initial_data,
|
||||
realm_creation=realm_creation,
|
||||
realm=realm,
|
||||
)
|
||||
else:
|
||||
form = RegistrationForm(initial=initial_data, realm_creation=realm_creation)
|
||||
form = RegistrationForm(
|
||||
initial=initial_data, realm_creation=realm_creation, realm=realm
|
||||
)
|
||||
else:
|
||||
postdata = request.POST.copy()
|
||||
if name_changes_disabled(realm):
|
||||
|
@ -443,7 +452,7 @@ def registration_helper(
|
|||
name_validated = True
|
||||
except KeyError:
|
||||
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):
|
||||
form["password"].field.required = False
|
||||
|
|
|
@ -490,7 +490,9 @@ def add_bot_backend(
|
|||
if bot_type != UserProfile.INCOMING_WEBHOOK_BOT:
|
||||
service_name = service_name or short_name
|
||||
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:
|
||||
email = Address(username=short_name, domain=user_profile.realm.get_bot_domain()).addr_spec
|
||||
except InvalidFakeEmailDomainError:
|
||||
|
@ -695,7 +697,9 @@ def create_user_backend(
|
|||
if not user_profile.can_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})
|
||||
if not form.is_valid():
|
||||
raise JsonableError(_("Bad name or username"))
|
||||
|
|
|
@ -930,7 +930,9 @@ class ZulipLDAPAuthBackendBase(ZulipAuthMixin, LDAPBackend):
|
|||
full_name = self.get_mapped_name(ldap_user)
|
||||
if full_name != user_profile.full_name:
|
||||
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:
|
||||
raise ZulipLDAPError(e.msg)
|
||||
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.
|
||||
full_name = self.get_mapped_name(ldap_user)
|
||||
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:
|
||||
raise ZulipLDAPError(e.msg)
|
||||
|
||||
|
|
Loading…
Reference in New Issue