invites: Switch new LIMITED-plan heuristic to enforcing.

This commit is contained in:
Alex Vandiver
2023-03-15 19:20:55 +00:00
committed by Tim Abbott
parent 50a2a54393
commit 330141f55d
3 changed files with 192 additions and 29 deletions

View File

@@ -1382,7 +1382,7 @@ class TestLoggingCountStats(AnalyticsTestCase):
stream, _ = self.create_stream_with_recipient() stream, _ = self.create_stream_with_recipient()
invite_expires_in_minutes = 2 * 24 * 60 invite_expires_in_minutes = 2 * 24 * 60
with mock.patch("zerver.actions.invites.apply_invite_realm_heuristics"): with mock.patch("zerver.actions.invites.too_many_recent_realm_invites", return_value=False):
do_invite_users( do_invite_users(
user, user,
["user1@domain.tld", "user2@domain.tld"], ["user1@domain.tld", "user2@domain.tld"],
@@ -1393,7 +1393,7 @@ class TestLoggingCountStats(AnalyticsTestCase):
# We currently send emails when re-inviting users that haven't # We currently send emails when re-inviting users that haven't
# turned into accounts, so count them towards the total # turned into accounts, so count them towards the total
with mock.patch("zerver.actions.invites.apply_invite_realm_heuristics"): with mock.patch("zerver.actions.invites.too_many_recent_realm_invites", return_value=False):
do_invite_users( do_invite_users(
user, user,
["user1@domain.tld", "user2@domain.tld"], ["user1@domain.tld", "user2@domain.tld"],
@@ -1404,7 +1404,7 @@ class TestLoggingCountStats(AnalyticsTestCase):
# Test mix of good and malformed invite emails # Test mix of good and malformed invite emails
with self.assertRaises(InvitationError), mock.patch( with self.assertRaises(InvitationError), mock.patch(
"zerver.actions.invites.apply_invite_realm_heuristics" "zerver.actions.invites.too_many_recent_realm_invites", return_value=False
): ):
do_invite_users( do_invite_users(
user, user,
@@ -1416,7 +1416,7 @@ class TestLoggingCountStats(AnalyticsTestCase):
# Test inviting existing users # Test inviting existing users
with self.assertRaises(InvitationError), mock.patch( with self.assertRaises(InvitationError), mock.patch(
"zerver.actions.invites.apply_invite_realm_heuristics" "zerver.actions.invites.too_many_recent_realm_invites", return_value=False
): ):
do_invite_users( do_invite_users(
user, user,
@@ -1433,7 +1433,7 @@ class TestLoggingCountStats(AnalyticsTestCase):
assertInviteCountEquals(5) assertInviteCountEquals(5)
# Resending invite should cost you # Resending invite should cost you
with mock.patch("zerver.actions.invites.apply_invite_realm_heuristics"): with mock.patch("zerver.actions.invites.too_many_recent_realm_invites", return_value=False):
do_resend_user_invite_email(assert_is_not_none(PreregistrationUser.objects.first())) do_resend_user_invite_email(assert_is_not_none(PreregistrationUser.objects.first()))
assertInviteCountEquals(6) assertInviteCountEquals(6)

View File

@@ -82,11 +82,16 @@ def estimate_recent_invites(realms: Collection[Realm], *, days: int) -> int:
return recent_invites return recent_invites
def apply_invite_realm_heuristics(realm: Realm, recent_invites: int, num_invitees: int) -> None: def too_many_recent_realm_invites(realm: Realm, num_invitees: int) -> bool:
# Basic check that we're blow the realm-set limit
recent_invites = estimate_recent_invites([realm], days=1)
if num_invitees + recent_invites > realm.max_invites:
return True
if realm.plan_type != Realm.PLAN_TYPE_LIMITED: if realm.plan_type != Realm.PLAN_TYPE_LIMITED:
return return False
if realm.max_invites != settings.INVITES_DEFAULT_REALM_DAILY_MAX: if realm.max_invites != settings.INVITES_DEFAULT_REALM_DAILY_MAX:
return return False
# If they're a non-paid plan with default invitation limits, # If they're a non-paid plan with default invitation limits,
# we further limit how many invitations can be sent in a day # we further limit how many invitations can be sent in a day
@@ -97,50 +102,50 @@ def apply_invite_realm_heuristics(realm: Realm, recent_invites: int, num_invitee
# unlikely to hit these limits. If a real realm hits them, # unlikely to hit these limits. If a real realm hits them,
# the resulting message suggests that they contact support if # the resulting message suggests that they contact support if
# they have a real use case. # they have a real use case.
suspicion_score = 0 warning_flags = []
if zxcvbn(realm.string_id)["score"] == 4: if zxcvbn(realm.string_id)["score"] == 4:
# Very high entropy realm names are suspicious # Very high entropy realm names are suspicious
suspicion_score += 1 warning_flags.append("random-realm-name")
if not realm.description: if not realm.description:
suspicion_score += 1 warning_flags.append("no-realm-description")
if realm.icon_source == Realm.ICON_FROM_GRAVATAR: if realm.icon_source == Realm.ICON_FROM_GRAVATAR:
suspicion_score += 1 warning_flags.append("no-realm-icon")
if realm.date_created >= timezone_now() - datetime.timedelta(hours=1): if realm.date_created >= timezone_now() - datetime.timedelta(hours=1):
suspicion_score += 1 warning_flags.append("realm-created-in-last-hour")
current_user_count = len(UserProfile.objects.filter(realm=realm, is_bot=False, is_active=True)) current_user_count = len(UserProfile.objects.filter(realm=realm, is_bot=False, is_active=True))
if current_user_count == 1: if current_user_count == 1:
suspicion_score += 1 warning_flags.append("only-one-user")
estimated_sent = RealmCount.objects.filter( estimated_sent = RealmCount.objects.filter(
realm=realm, property="messages_sent:message_type:day" realm=realm, property="messages_sent:message_type:day"
).aggregate(messages=Sum("value")) ).aggregate(messages=Sum("value"))
if not estimated_sent["messages"]: if not estimated_sent["messages"]:
suspicion_score += 1 warning_flags.append("no-messages-sent")
if suspicion_score == 6: if len(warning_flags) == 6:
permitted_ratio = 2 permitted_ratio = 2
elif suspicion_score >= 3: elif len(warning_flags) >= 3:
permitted_ratio = 3 permitted_ratio = 3
else: else:
permitted_ratio = 5 permitted_ratio = 5
# For now, simply log the data; this will change to a raise of ratio = (num_invitees + recent_invites) / current_user_count
# InvitationError once we've done some auditing. logging.log(
logging.warning( logging.WARNING if ratio > permitted_ratio else logging.INFO,
"%s (suspicion %d/6) inviting %d more, have %d recent, %d max, but only %d current users. Ratio %.1f, %d allowed", "%s (!: %s) inviting %d more, have %d recent, but only %d current users. Ratio %.1f, %d allowed",
realm.string_id, realm.string_id,
suspicion_score, ",".join(warning_flags),
num_invitees, num_invitees,
recent_invites, recent_invites,
realm.max_invites,
current_user_count, current_user_count,
(num_invitees + recent_invites) / current_user_count, ratio,
permitted_ratio, permitted_ratio,
) )
return ratio > permitted_ratio
def check_invite_limit(realm: Realm, num_invitees: int) -> None: def check_invite_limit(realm: Realm, num_invitees: int) -> None:
@@ -151,8 +156,7 @@ def check_invite_limit(realm: Realm, num_invitees: int) -> None:
if not settings.OPEN_REALM_CREATION: if not settings.OPEN_REALM_CREATION:
return return
recent_invites = estimate_recent_invites([realm], days=1) if too_many_recent_realm_invites(realm, num_invitees):
if num_invitees + recent_invites > realm.max_invites:
raise InvitationError( raise InvitationError(
msg, msg,
[], [],
@@ -160,8 +164,6 @@ def check_invite_limit(realm: Realm, num_invitees: int) -> None:
daily_limit_reached=True, daily_limit_reached=True,
) )
apply_invite_realm_heuristics(realm, recent_invites, num_invitees)
default_max = settings.INVITES_DEFAULT_REALM_DAILY_MAX default_max = settings.INVITES_DEFAULT_REALM_DAILY_MAX
newrealm_age = datetime.timedelta(days=settings.INVITES_NEW_REALM_DAYS) newrealm_age = datetime.timedelta(days=settings.INVITES_NEW_REALM_DAYS)
if realm.date_created <= timezone_now() - newrealm_age: if realm.date_created <= timezone_now() - newrealm_age:

View File

@@ -11,6 +11,7 @@ from django.conf import settings
from django.core import mail from django.core import mail
from django.core.mail.message import EmailMultiAlternatives from django.core.mail.message import EmailMultiAlternatives
from django.http import HttpRequest from django.http import HttpRequest
from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
@@ -22,14 +23,16 @@ from confirmation.models import (
get_object_from_key, get_object_from_key,
) )
from corporate.lib.stripe import get_latest_seat_count from corporate.lib.stripe import get_latest_seat_count
from zerver.actions.create_realm import do_change_realm_subdomain, do_create_realm
from zerver.actions.create_user import do_create_user, process_new_human_user from zerver.actions.create_user import do_create_user, process_new_human_user
from zerver.actions.invites import ( from zerver.actions.invites import (
do_create_multiuse_invite_link, do_create_multiuse_invite_link,
do_get_invites_controlled_by_user, do_get_invites_controlled_by_user,
do_invite_users, do_invite_users,
do_revoke_multi_use_invite, do_revoke_multi_use_invite,
too_many_recent_realm_invites,
) )
from zerver.actions.realm_settings import do_set_realm_property from zerver.actions.realm_settings import do_change_realm_plan_type, do_set_realm_property
from zerver.actions.user_settings import do_change_full_name from zerver.actions.user_settings import do_change_full_name
from zerver.actions.users import change_user_is_active from zerver.actions.users import change_user_is_active
from zerver.context_processors import common_context from zerver.context_processors import common_context
@@ -267,6 +270,164 @@ class InviteUserTest(InviteUserBase):
self.assert_json_error_contains(result, "reached the limit") self.assert_json_error_contains(result, "reached the limit")
self.check_sent_emails([]) self.check_sent_emails([])
@override_settings(OPEN_REALM_CREATION=True)
def test_limited_plan_heuristics(self) -> None:
# There additional limits only apply if OPEN_REALM_CREATION is
# True and the plan is "limited," which is primarily only
# relevant on Zulip Cloud.
realm = do_create_realm("sdfoijt23489fuskdfjhksdf", "Totally Normal")
realm.plan_type = Realm.PLAN_TYPE_LIMITED
realm.invite_required = False
realm.save()
# Create a first user
admin_user = do_create_user(
"someone@example.com",
"password",
realm,
"full name",
role=UserProfile.ROLE_REALM_OWNER,
realm_creation=True,
acting_user=None,
)
# Inviting would work at all
with self.assertLogs(level="INFO") as m:
self.assertFalse(too_many_recent_realm_invites(realm, 1))
self.assertEqual(
m.output,
[
(
"INFO:root:sdfoijt23489fuskdfjhksdf "
"(!: random-realm-name,no-realm-description,no-realm-icon,realm-created-in-last-hour,only-one-user,no-messages-sent) "
"inviting 1 more, have 0 recent, but only 1 current users. "
"Ratio 1.0, 2 allowed"
)
],
)
# This realm is currently very suspicious, so can only invite
# 2 users at once (2x current 1 user)
with self.assertLogs(level="INFO") as m:
self.assertFalse(too_many_recent_realm_invites(realm, 2))
self.assertTrue(too_many_recent_realm_invites(realm, 3))
self.assertEqual(
m.output,
[
(
"INFO:root:sdfoijt23489fuskdfjhksdf "
"(!: random-realm-name,no-realm-description,no-realm-icon,realm-created-in-last-hour,only-one-user,no-messages-sent) "
"inviting 2 more, have 0 recent, but only 1 current users. "
"Ratio 2.0, 2 allowed"
),
(
"WARNING:root:sdfoijt23489fuskdfjhksdf "
"(!: random-realm-name,no-realm-description,no-realm-icon,realm-created-in-last-hour,only-one-user,no-messages-sent) "
"inviting 3 more, have 0 recent, but only 1 current users. "
"Ratio 3.0, 2 allowed"
),
],
)
# Having another user makes it slightly less suspicious, and
# also able to invite more in ratio with the current count of
# users (3x current 2 users)
self.register("other@example.com", "test", subdomain=realm.string_id)
with self.assertLogs(level="INFO") as m:
self.assertFalse(too_many_recent_realm_invites(realm, 6))
self.assertTrue(too_many_recent_realm_invites(realm, 7))
self.assertEqual(
m.output,
[
(
"INFO:root:sdfoijt23489fuskdfjhksdf "
"(!: random-realm-name,no-realm-description,no-realm-icon,realm-created-in-last-hour,no-messages-sent) "
"inviting 6 more, have 0 recent, but only 2 current users. "
"Ratio 3.0, 3 allowed"
),
(
"WARNING:root:sdfoijt23489fuskdfjhksdf "
"(!: random-realm-name,no-realm-description,no-realm-icon,realm-created-in-last-hour,no-messages-sent) "
"inviting 7 more, have 0 recent, but only 2 current users. "
"Ratio 3.5, 3 allowed"
),
],
)
# Remove some more warning flags
do_change_realm_subdomain(realm, "reasonable", acting_user=None)
realm.description = "A real place"
realm.date_created = timezone_now() - datetime.timedelta(hours=2)
realm.save()
# This is now more allowable (5x current 2 users)
with self.assertLogs(level="INFO") as m:
self.assertFalse(too_many_recent_realm_invites(realm, 10))
self.assertTrue(too_many_recent_realm_invites(realm, 11))
self.assertEqual(
m.output,
[
(
"INFO:root:reasonable "
"(!: no-realm-icon,no-messages-sent) "
"inviting 10 more, have 0 recent, but only 2 current users. "
"Ratio 5.0, 5 allowed"
),
(
"WARNING:root:reasonable "
"(!: no-realm-icon,no-messages-sent) "
"inviting 11 more, have 0 recent, but only 2 current users. "
"Ratio 5.5, 5 allowed"
),
],
)
# If we have a different max_invites on the realm that kicks in, though
realm.max_invites = 8
realm.save()
self.assertFalse(too_many_recent_realm_invites(realm, 8))
self.assertTrue(too_many_recent_realm_invites(realm, 9))
# And if we have a non-default max invite then that applies
# but not the heuristics (which would limit us to 10, here)
realm.max_invites = 12
realm.save()
self.assertFalse(too_many_recent_realm_invites(realm, 12))
self.assertTrue(too_many_recent_realm_invites(realm, 13))
# Not being a limited plan also opens us up from the
# heuristics. First, set us back to the default invite limit
realm.max_invites = settings.INVITES_DEFAULT_REALM_DAILY_MAX
realm.save()
with self.assertLogs(level="INFO") as m:
self.assertFalse(too_many_recent_realm_invites(realm, 10))
self.assertTrue(too_many_recent_realm_invites(realm, 11))
self.assertEqual(
m.output,
[
(
"INFO:root:reasonable "
"(!: no-realm-icon,no-messages-sent) "
"inviting 10 more, have 0 recent, but only 2 current users. "
"Ratio 5.0, 5 allowed"
),
(
"WARNING:root:reasonable "
"(!: no-realm-icon,no-messages-sent) "
"inviting 11 more, have 0 recent, but only 2 current users. "
"Ratio 5.5, 5 allowed"
),
],
)
# Become a Standard plan
do_change_realm_plan_type(realm, Realm.PLAN_TYPE_STANDARD, acting_user=admin_user)
self.assertFalse(too_many_recent_realm_invites(realm, 3000))
self.assertTrue(too_many_recent_realm_invites(realm, 3001))
do_change_realm_plan_type(realm, Realm.PLAN_TYPE_STANDARD_FREE, acting_user=admin_user)
self.assertFalse(too_many_recent_realm_invites(realm, 3000))
self.assertTrue(too_many_recent_realm_invites(realm, 3001))
def test_invite_user_to_realm_on_manual_license_plan(self) -> None: def test_invite_user_to_realm_on_manual_license_plan(self) -> None:
user = self.example_user("hamlet") user = self.example_user("hamlet")
self.login_user(user) self.login_user(user)