confirmation: Replace RealmCreationKey - use Confirmation instead.

Fixes #20028.

There's no reason to have a special `RealmCreationKey` class - the
`Confirmation` system already does this job.

This is somewhat complicated by the need to write a migration for
`RealmCreationKey`->`Confirmation` for pre-existing, valid objects, to
avoid breaking realm creation links that haven't been used yet.
This commit is contained in:
Mateusz Mandera
2025-07-06 02:13:02 +08:00
committed by Tim Abbott
parent 072f234269
commit 40b1f6eb4e
13 changed files with 238 additions and 77 deletions

View File

@@ -0,0 +1,112 @@
# Generated by Django 5.2.3 on 2025-07-04 20:33
from datetime import timedelta
from django.conf import settings
from django.db import migrations, transaction
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.migrations.state import StateApps
from django.db.models import Max
def migrate_realmcreationkey_to_realmcreationstatus(
apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
) -> None:
"""
This migration is for switching from using a separate RealmCreationKey class
for realm creation keys to just using the Confirmation system.
The aim is to iterate through all the existing RealmCreationKey and create
a corresponding Confirmation+RealmCreationStatus.
For validity of these objects, we only need to worry about RealmCreationKeys
expired due to time. This is taken care of by making sure we set expiry_date
on the Confirmation we're creating.
The way the RealmCreationKey system worked was to .delete() the RealmCreationKey
objects when they were used - so those simply no longer exist and we don't need to
worry about this case.
"""
CAN_CREATE_REALM = 11
Confirmation = apps.get_model("confirmation", "Confirmation")
ContentType = apps.get_model("contenttypes", "ContentType")
RealmCreationKey = apps.get_model("confirmation", "RealmCreationKey")
RealmCreationStatus = apps.get_model("zerver", "RealmCreationStatus")
realm_creation_status_content_type, created = ContentType.objects.get_or_create(
model="realmcreationstatus", app_label="zerver"
)
BATCH_SIZE = 10000
max_id = RealmCreationKey.objects.aggregate(Max("id"))["id__max"]
if max_id is None:
# Nothing to do.
return
lower_bound = 1
while lower_bound <= max_id + (BATCH_SIZE / 2):
upper_bound = lower_bound + BATCH_SIZE - 1
creation_keys = RealmCreationKey.objects.filter(id__range=(lower_bound, upper_bound))
creation_statuses_to_create = []
confirmations_to_create = []
for creation_key in creation_keys:
# These keys were generated in the same way as keys for Confirmation objects,
# so we can copy them over without breaking anything.
key = creation_key.creation_key
date_created = creation_key.date_created
presume_email_valid = creation_key.presume_email_valid
creation_status = RealmCreationStatus(
status=0, date_created=date_created, presume_email_valid=presume_email_valid
)
confirmation = Confirmation(
content_type=realm_creation_status_content_type,
type=CAN_CREATE_REALM,
confirmation_key=key,
date_sent=date_created,
expiry_date=date_created
+ timedelta(days=settings.CAN_CREATE_REALM_LINK_VALIDITY_DAYS),
)
# To attach the Confirmations to RealmCreationStatus objects we need to set the
# confirmation.object_id to their respective ids. But we haven't saved
# the RealmCreationStatus objs to the database yet - so we don't have ids.
#
# After we .bulk_create() them, their .id attributes will be populated.
# So for now we just link the RealmCreationStatus to the Confirmation
# via a temporary ._object attribute - which we'll use later to set
# the .object_id as intended.
confirmation._object = creation_status
creation_statuses_to_create.append(creation_status)
confirmations_to_create.append(confirmation)
with transaction.atomic():
RealmCreationStatus.objects.bulk_create(creation_statuses_to_create)
# Now the objects in creation_statuses_to_create have had their .id
# attrs populated. For every confirmation, confirmation._object
# points to its corresponding RealmCreationStatus - so now we can
# set the confirmation.object_id values and clear out the temporary
# ._object attr.
for confirmation in confirmations_to_create:
confirmation.object_id = confirmation._object.id
delattr(confirmation, "_object")
Confirmation.objects.bulk_create(confirmations_to_create)
lower_bound += BATCH_SIZE
class Migration(migrations.Migration):
atomic = False
dependencies = [
("confirmation", "0015_alter_confirmation_object_id"),
("zerver", "0743_realmcreationstatus"),
]
operations = [
migrations.RunPython(
migrate_realmcreationkey_to_realmcreationstatus,
reverse_code=migrations.RunPython.noop,
elidable=True,
),
]

View File

@@ -0,0 +1,15 @@
# Generated by Django 5.2.3 on 2025-07-04 19:08
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("confirmation", "0016_realmcreationkey_to_realmcreationstatus"),
]
operations = [
migrations.DeleteModel(
name="RealmCreationKey",
),
]

View File

@@ -5,7 +5,7 @@ import secrets
from base64 import b32encode
from collections.abc import Mapping
from datetime import timedelta
from typing import Optional, TypeAlias, Union, cast
from typing import TypeAlias, Union, cast
from urllib.parse import urljoin
from django.conf import settings
@@ -30,6 +30,7 @@ from zerver.models import (
RealmReactivationStatus,
UserProfile,
)
from zerver.models.prereg_users import RealmCreationStatus
if settings.ZILENCER_ENABLED:
from zilencer.models import (
@@ -70,6 +71,7 @@ NoZilencerConfirmationObjT: TypeAlias = (
| EmailChangeStatus
| UserProfile
| RealmReactivationStatus
| RealmCreationStatus
)
ZilencerConfirmationObjT: TypeAlias = Union[
NoZilencerConfirmationObjT,
@@ -153,7 +155,7 @@ def create_confirmation_object(
realm = None
else:
obj = cast(NoZilencerConfirmationObjT, obj)
assert not isinstance(obj, PreregistrationRealm)
assert not isinstance(obj, PreregistrationRealm | RealmCreationStatus)
realm = obj.realm
current_time = timezone_now()
@@ -185,15 +187,17 @@ def create_confirmation_link(
url_args: Mapping[str, str] = {},
no_associated_realm_object: bool = False,
) -> str:
return confirmation_url_for(
create_confirmation_object(
obj,
confirmation_type,
validity_in_minutes=validity_in_minutes,
no_associated_realm_object=no_associated_realm_object,
),
conf = create_confirmation_object(
obj,
confirmation_type,
validity_in_minutes=validity_in_minutes,
no_associated_realm_object=no_associated_realm_object,
)
result = confirmation_url_for(
conf,
url_args=url_args,
)
return result
def confirmation_url_for(confirmation_obj: "Confirmation", url_args: Mapping[str, str] = {}) -> str:
@@ -236,6 +240,7 @@ class Confirmation(models.Model):
REALM_REACTIVATION = 8
REMOTE_SERVER_BILLING_LEGACY_LOGIN = 9
REMOTE_REALM_BILLING_LEGACY_LOGIN = 10
CAN_CREATE_REALM = 11
type = models.PositiveSmallIntegerField()
class Meta:
@@ -272,6 +277,9 @@ _properties = {
Confirmation.MULTIUSE_INVITE: ConfirmationType(
"join", validity_in_days=settings.INVITATION_LINK_VALIDITY_DAYS
),
Confirmation.CAN_CREATE_REALM: ConfirmationType(
"create_realm", validity_in_days=settings.CAN_CREATE_REALM_LINK_VALIDITY_DAYS
),
Confirmation.NEW_REALM_USER_REGISTRATION: ConfirmationType("get_prereg_key_and_redirect"),
Confirmation.REALM_REACTIVATION: ConfirmationType("realm_reactivation_get"),
}
@@ -294,47 +302,7 @@ def one_click_unsubscribe_link(user_profile: UserProfile, email_type: str) -> st
)
# Functions related to links generated by the generate_realm_creation_link.py
# management command.
# Note that being validated here will just allow the user to access the create_realm
# form, where they will enter their email and go through the regular
# Confirmation.NEW_REALM_USER_REGISTRATION pathway.
# Arguably RealmCreationKey should just be another ConfirmationObjT and we should
# add another Confirmation.type for this; it's this way for historical reasons.
def validate_key(creation_key: str | None) -> Optional["RealmCreationKey"]:
"""Get the record for this key, raising InvalidCreationKey if non-None but invalid."""
if creation_key is None:
return None
try:
key_record = RealmCreationKey.objects.get(creation_key=creation_key)
except RealmCreationKey.DoesNotExist:
raise RealmCreationKey.InvalidError
time_elapsed = timezone_now() - key_record.date_created
if time_elapsed.total_seconds() > settings.REALM_CREATION_LINK_VALIDITY_DAYS * 24 * 3600:
raise RealmCreationKey.InvalidError
return key_record
def generate_realm_creation_url(by_admin: bool = False) -> str:
key = generate_key()
RealmCreationKey.objects.create(
creation_key=key, date_created=timezone_now(), presume_email_valid=by_admin
)
return urljoin(
settings.ROOT_DOMAIN_URI,
reverse("create_realm", kwargs={"creation_key": key}),
)
from zerver.views.registration import prepare_realm_creation_url
class RealmCreationKey(models.Model):
creation_key = models.CharField("activation key", db_index=True, max_length=40)
date_created = models.DateTimeField("created", default=timezone_now)
# True just if we should presume the email address the user enters
# is theirs, and skip sending mail to it to confirm that.
presume_email_valid = models.BooleanField(default=False)
class InvalidError(Exception):
pass
return prepare_realm_creation_url(presume_email_valid=by_admin)

View File

@@ -217,6 +217,8 @@ _Released 2025-07-29_
preserve and update your `postfix` configuration.
- The `SOCIAL_AUTH_SYNC_CUSTOM_ATTRS_DICT` setting has been removed.
It was deprecated in favor of `SOCIAL_AUTH_SYNC_ATTRS_DICT` in 10.0.
- The obscure `REALM_CREATION_LINK_VALIDITY_DAYS` setting was renamed to
`CAN_CREATE_REALM_LINK_VALIDITY_DAYS`.
## Zulip Server 10.x series

View File

@@ -32,7 +32,7 @@ The above command will output a URL which can be used for creating a
new realm and an administrator user for that realm. The link expires
after the creation of the realm. The link also expires if not used
within 7 days. The expiration period can be changed by modifying
`REALM_CREATION_LINK_VALIDITY_DAYS` in settings.py.
`CAN_CREATE_REALM_LINK_VALIDITY_DAYS` in settings.py.
## Subdomains

View File

@@ -182,6 +182,7 @@ ALL_ZULIP_TABLES = {
"zerver_realm",
"zerver_realmauditlog",
"zerver_realmauthenticationmethod",
"zerver_realmcreationstatus",
"zerver_realmdomain",
"zerver_realmemoji",
"zerver_realmexport",
@@ -230,6 +231,7 @@ NON_EXPORTED_TABLES = {
"zerver_preregistrationuser_streams",
"zerver_preregistrationuser_groups",
"zerver_realmreactivationstatus",
"zerver_realmcreationstatus",
# Missed message addresses are low value to export since
# missed-message email addresses include the server's hostname and
# expire after a few days.

View File

@@ -14,7 +14,7 @@ class Command(ZulipBaseCommand):
Outputs a randomly generated, 1-time-use link for Organization creation.
Whoever visits the link can create a new organization on this server, regardless of whether
settings.OPEN_REALM_CREATION is enabled. The link would expire automatically after
settings.REALM_CREATION_LINK_VALIDITY_DAYS.
settings.CAN_CREATE_REALM_LINK_VALIDITY_DAYS.
Usage: ./manage.py generate_realm_creation_link """

View File

@@ -0,0 +1,27 @@
# Generated by Django 5.2.3 on 2025-07-04 19:08
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zerver", "0746_alter_channelfolder_unique_together_and_more"),
]
operations = [
migrations.CreateModel(
name="RealmCreationStatus",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("status", models.IntegerField(default=0)),
("date_created", models.DateTimeField(default=django.utils.timezone.now)),
("presume_email_valid", models.BooleanField(default=False)),
],
),
]

View File

@@ -170,3 +170,14 @@ class RealmReactivationStatus(models.Model):
status = models.IntegerField(default=0)
realm = models.ForeignKey(Realm, on_delete=CASCADE)
class RealmCreationStatus(models.Model):
# status: whether an object has been confirmed.
# if confirmed, set to confirmation.settings.STATUS_USED
status = models.IntegerField(default=0)
date_created = models.DateTimeField(default=timezone_now)
# True just if we should presume the email address the user enters
# is theirs, and skip sending mail to it to confirm that.
presume_email_valid = models.BooleanField(default=False)

View File

@@ -13,7 +13,7 @@ from django.core.management.base import CommandError
from django.test import override_settings
from typing_extensions import override
from confirmation.models import RealmCreationKey, generate_realm_creation_url
from confirmation.models import Confirmation, generate_realm_creation_url
from zerver.actions.create_user import do_create_user
from zerver.actions.user_settings import do_change_user_setting
from zerver.lib.management import ZulipBaseCommand, check_config
@@ -360,11 +360,13 @@ class TestGenerateRealmCreationLink(ZulipTestCase):
@override_settings(OPEN_REALM_CREATION=False)
def test_realm_creation_with_expired_link(self) -> None:
generated_link = generate_realm_creation_url(by_admin=True)
key = generated_link[-24:]
# Manually expire the link by changing the date of creation
obj = RealmCreationKey.objects.get(creation_key=key)
obj.date_created -= timedelta(days=settings.REALM_CREATION_LINK_VALIDITY_DAYS + 1)
obj.save()
key = generated_link.split("/")[-1]
# Manually expire the link by changing the date of expiry.
confirmation = Confirmation.objects.get(confirmation_key=key)
assert confirmation.expiry_date is not None
confirmation.expiry_date -= timedelta(days=settings.CAN_CREATE_REALM_LINK_VALIDITY_DAYS + 1)
confirmation.save()
result = self.client_get(generated_link)
self.assert_in_success_response(["Organization creation link expired or invalid"], result)

View File

@@ -1,7 +1,7 @@
import logging
from collections.abc import Iterable
from contextlib import suppress
from typing import Annotated, Any
from typing import Annotated, Any, cast
from urllib.parse import urlencode, urljoin
import orjson
@@ -27,11 +27,9 @@ from confirmation import settings as confirmation_settings
from confirmation.models import (
Confirmation,
ConfirmationKeyError,
RealmCreationKey,
create_confirmation_link,
get_object_from_key,
render_confirmation_key_error,
validate_key,
)
from zerver.actions.create_realm import do_create_realm
from zerver.actions.create_user import do_activate_mirror_dummy_user, do_create_user
@@ -101,6 +99,7 @@ from zerver.models import (
UserProfile,
)
from zerver.models.constants import MAX_LANGUAGE_ID_LENGTH
from zerver.models.prereg_users import RealmCreationStatus
from zerver.models.realm_audit_logs import AuditLogEventType, RealmAuditLog
from zerver.models.realms import (
DisposableEmailError,
@@ -1006,6 +1005,19 @@ def prepare_realm_activation_url(
return activation_url
def prepare_realm_creation_url(
presume_email_valid: bool = False,
) -> str:
realm_creation_status = RealmCreationStatus.objects.create(
presume_email_valid=presume_email_valid
)
confirmation_url = create_confirmation_link(
realm_creation_status, Confirmation.CAN_CREATE_REALM, no_associated_realm_object=True
)
return confirmation_url
def send_confirm_registration_email(
email: str,
activation_url: str,
@@ -1246,15 +1258,9 @@ def realm_import_post_process(
@add_google_analytics
def create_realm(request: HttpRequest, creation_key: str | None = None) -> HttpResponse:
try:
key_record = validate_key(creation_key)
except RealmCreationKey.InvalidError:
return TemplateResponse(
request,
"zerver/portico_error_pages/realm_creation_link_invalid.html",
)
if key_record is None:
def create_realm(request: HttpRequest, confirmation_key: str | None = None) -> HttpResponse:
if confirmation_key is None:
realm_creation_obj: RealmCreationStatus | None = None
if not settings.OPEN_REALM_CREATION:
return TemplateResponse(
request,
@@ -1265,6 +1271,19 @@ def create_realm(request: HttpRequest, creation_key: str | None = None) -> HttpR
request,
"zerver/portico_error_pages/realm_creation_disabled.html",
)
else:
try:
realm_creation_obj = cast(
RealmCreationStatus,
get_object_from_key(
confirmation_key, [Confirmation.CAN_CREATE_REALM], mark_object_used=False
),
)
except ConfirmationKeyError:
return TemplateResponse(
request,
"zerver/portico_error_pages/realm_creation_link_invalid.html",
)
# When settings.OPEN_REALM_CREATION is enabled, anyone can create a new realm,
# with a few restrictions on their email address.
@@ -1300,12 +1319,13 @@ def create_realm(request: HttpRequest, creation_key: str | None = None) -> HttpR
realm_default_language,
import_from,
)
if key_record is not None and key_record.presume_email_valid:
if realm_creation_obj is not None and realm_creation_obj.presume_email_valid:
# The user has a token created from the server command line;
# skip confirming the email is theirs, taking their word for it.
# This is essential on first install if the admin hasn't stopped
# to configure outbound email up front, or it isn't working yet.
key_record.delete()
realm_creation_obj.status = getattr(settings, "STATUS_USED", 1)
realm_creation_obj.save(update_fields=["status"])
return HttpResponseRedirect(activation_url)
try:
@@ -1322,8 +1342,10 @@ def create_realm(request: HttpRequest, creation_key: str | None = None) -> HttpR
return render(request, "500.html", status=500)
return config_error(request, "smtp")
if key_record is not None:
key_record.delete()
if realm_creation_obj is not None:
realm_creation_obj.status = getattr(settings, "STATUS_USED", 1)
realm_creation_obj.save(update_fields=["status"])
url = reverse(
"new_realm_send_confirm",
query={

View File

@@ -516,7 +516,7 @@ PERSONAL_ZMIRROR_SERVER: str | None = None
# When security-relevant links in emails expire.
CONFIRMATION_LINK_DEFAULT_VALIDITY_DAYS = 1
INVITATION_LINK_VALIDITY_DAYS = 10
REALM_CREATION_LINK_VALIDITY_DAYS = 7
CAN_CREATE_REALM_LINK_VALIDITY_DAYS = 7
# Version number for ToS. Change this if you want to force every
# user to click through to re-accept terms of service before using

View File

@@ -698,7 +698,7 @@ i18n_urls = [
# Realm creation
path("json/antispam_challenge", get_challenge),
path("new/", create_realm),
path("new/<creation_key>", create_realm, name="create_realm"),
path("new/<confirmation_key>", create_realm, name="create_realm"),
# Realm reactivation
path("reactivate/", realm_reactivation, name="realm_reactivation"),
path("reactivate/<confirmation_key>", realm_reactivation_get, name="realm_reactivation_get"),