realm: Add option to schedule data deletion while deactivating.

Introduce a feature to schedule realm data deletion time during realm
deactivation. This includes a server-level setting to configure the
minimum and maximum allowed deletion days.

Co-authored-by: Ujjawal Modi <umodi2003@gmail.com>
Co-authored-by: Lauryn Menard <lauryn@zulip.com>

Fixes #24677.
This commit is contained in:
opmkumar
2024-12-18 00:22:09 +05:30
committed by Tim Abbott
parent 219c3b56df
commit 5b0c55fda3
16 changed files with 515 additions and 15 deletions

View File

@@ -20,6 +20,17 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 10.0 ## Changes in Zulip 10.0
**Feature level 332**
* [`POST /register`](/api/register-queue): Added
`server_min_deactivated_realm_deletion_days` and
`server_max_deactivated_realm_deletion_days` fields for the permitted
number of days before full data deletion of a deactivated organization
on the server.
* `POST /realm/deactivate`: Added `deletion_delay_days` parameter to
support setting when a full data deletion of the deactivated
organization may be done.
**Feature level 331** **Feature level 331**
* [`POST /register`](/api/register-queue), [`POST /events`](/api/get-events), * [`POST /register`](/api/register-queue), [`POST /events`](/api/get-events),

View File

@@ -34,7 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3"
# 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 = 331 # Last bumped for realm-level setting, `moderation_request_channel`. API_FEATURE_LEVEL = 332 # Last bumped for data deletion of deactivated realms.
# 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

@@ -512,6 +512,53 @@ export const custom_time_unit_values = {
}, },
}; };
export const realm_deletion_in_values = {
immediately: {
value: 0,
description: $t({defaultMessage: "Immediately"}),
default: false,
},
fourteen_days: {
value: 14 * 24 * 60,
description: $t({defaultMessage: "14 days"}),
default: true,
},
thirty_days: {
value: 30 * 24 * 60,
description: $t({defaultMessage: "30 days"}),
default: false,
},
ninty_days: {
value: 90 * 24 * 60,
description: $t({defaultMessage: "90 days"}),
default: false,
},
one_year: {
value: 365 * 24 * 60,
description: $t({defaultMessage: "1 year"}),
default: false,
},
two_years: {
value: 365 * 24 * 60 * 2,
description: $t({defaultMessage: "2 years"}),
default: false,
},
never: {
// Ideally we'd just store `null`, not the string `"null"`, but
// .val() will read null back as `""`. Custom logic in
// do_deactivate_realm converts this back to `null`
// before sending to the server.
value: "null",
description: $t({defaultMessage: "Never"}),
default: false,
},
custom: {
value: "custom",
description: $t({defaultMessage: "Custom"}),
default: false,
},
};
const user_role_array = Object.values(user_role_values); const user_role_array = Object.values(user_role_values);
export const user_role_map = new Map(user_role_array.map((role) => [role.code, role.description])); export const user_role_map = new Map(user_role_array.map((role) => [role.code, role.description]));

View File

@@ -1,3 +1,4 @@
import {add} from "date-fns";
import $ from "jquery"; import $ from "jquery";
import assert from "minimalistic-assert"; import assert from "minimalistic-assert";
import {z} from "zod"; import {z} from "zod";
@@ -39,6 +40,7 @@ import {current_user, realm, realm_schema} from "./state_data.ts";
import type {Realm} from "./state_data.ts"; import type {Realm} from "./state_data.ts";
import * as stream_settings_data from "./stream_settings_data.ts"; import * as stream_settings_data from "./stream_settings_data.ts";
import type {StreamSubscription} from "./sub_store.ts"; import type {StreamSubscription} from "./sub_store.ts";
import * as timerender from "./timerender.ts";
import {group_setting_value_schema} from "./types.ts"; import {group_setting_value_schema} from "./types.ts";
import type {HTMLSelectOneElement} from "./types.ts"; import type {HTMLSelectOneElement} from "./types.ts";
import * as ui_report from "./ui_report.ts"; import * as ui_report from "./ui_report.ts";
@@ -754,24 +756,219 @@ export function deactivate_organization(e: JQuery.Event): void {
e.stopPropagation(); e.stopPropagation();
function do_deactivate_realm(): void { function do_deactivate_realm(): void {
const raw_delete_in = $<HTMLSelectOneElement>(
"select:not([multiple])#delete-realm-data-in",
).val()!;
let delete_in_days: number | null;
// See settings_config.realm_deletion_in_values for why we do this conversion.
if (raw_delete_in === "null") {
delete_in_days = null;
} else if (raw_delete_in === "custom") {
const deletes_in_minutes = util.get_custom_time_in_minutes(
custom_deletion_time_unit,
custom_deletion_time_input,
);
delete_in_days = deletes_in_minutes / (60 * 24);
} else {
const deletes_in_minutes = Number.parseFloat(raw_delete_in);
delete_in_days = deletes_in_minutes / (60 * 24);
}
const data = {
deletion_delay_days: JSON.stringify(delete_in_days),
};
channel.post({ channel.post({
url: "/json/realm/deactivate", url: "/json/realm/deactivate",
data,
error(xhr) { error(xhr) {
ui_report.error($t_html({defaultMessage: "Failed"}), xhr, $("#dialog_error")); ui_report.error($t_html({defaultMessage: "Failed"}), xhr, $("#dialog_error"));
}, },
}); });
} }
const html_body = render_settings_deactivate_realm_modal(); let custom_deletion_time_input = realm.server_min_deactivated_realm_deletion_days ?? 0;
let custom_deletion_time_unit = settings_config.custom_time_unit_values.days.name;
function delete_data_in_text(): string {
const $delete_in = $<HTMLSelectOneElement>("select:not([multiple])#delete-realm-data-in");
const delete_data_value = $delete_in.val()!;
if (delete_data_value === "null") {
return $t({defaultMessage: "Data will not be automatically deleted"});
}
let time_in_minutes: number;
if (delete_data_value === "custom") {
if (!util.validate_custom_time_input(custom_deletion_time_input)) {
return $t({defaultMessage: "Invalid custom time"});
}
time_in_minutes = util.get_custom_time_in_minutes(
custom_deletion_time_unit,
custom_deletion_time_input,
);
if (!is_valid_time_period(time_in_minutes)) {
return $t({defaultMessage: "Invalid custom time"});
}
} else {
// These options were already filtered for is_valid_time_period.
time_in_minutes = Number.parseFloat(delete_data_value);
}
if (time_in_minutes === 0) {
return $t({defaultMessage: "Data will be deleted immediately"});
}
// The below is a duplicate of timerender.get_full_datetime, with a different base string.
const valid_to = add(new Date(), {minutes: time_in_minutes});
const date = timerender.get_localized_date_or_time_for_format(valid_to, "dayofyear_year");
return $t({defaultMessage: "Data will be deleted after {date}"}, {date});
}
const minimum_allowed_days = realm.server_min_deactivated_realm_deletion_days ?? 0;
const maximum_allowed_days = realm.server_max_deactivated_realm_deletion_days;
function is_valid_time_period(time_period: string | number): boolean {
if (time_period === "custom") {
return true;
}
if (time_period === "null") {
if (maximum_allowed_days === null) {
return true;
}
return false;
}
if (typeof time_period === "number") {
if (maximum_allowed_days === null) {
if (time_period >= minimum_allowed_days * 24 * 60) {
return true;
}
} else {
if (
time_period >= minimum_allowed_days * 24 * 60 &&
time_period <= maximum_allowed_days * 24 * 60
) {
return true;
}
}
}
return false;
}
function get_custom_deletion_input_text(): string {
if (maximum_allowed_days === null) {
if (minimum_allowed_days === 0) {
// If there's no limit at all, avoid showing 0+. It's
// not a marginal string for translators, since we use
// that string elsewhere.
return $t({defaultMessage: "Custom time"});
}
return $t({defaultMessage: `Custom time ({min}+ days)`}, {min: minimum_allowed_days});
}
return $t(
{defaultMessage: `Custom time ({min}-{max} days)`},
{min: minimum_allowed_days, max: maximum_allowed_days},
);
}
function toggle_deactivate_submit_button(): void {
const $delete_in = $<HTMLSelectOneElement>("select:not([multiple])#delete-realm-data-in");
const valid_custom_time =
util.validate_custom_time_input(custom_deletion_time_input) &&
is_valid_time_period(
util.get_custom_time_in_minutes(
custom_deletion_time_unit,
custom_deletion_time_input,
),
);
$("#deactivate-realm-user-modal .dialog_submit_button").prop(
"disabled",
$delete_in.val() === "custom" && !valid_custom_time,
);
}
function deactivate_realm_modal_post_render(): void {
settings_components.set_custom_time_inputs_visibility(
$("#delete-realm-data-in"),
custom_deletion_time_unit,
custom_deletion_time_input,
);
settings_components.set_time_input_formatted_text(
$("#delete-realm-data-in"),
delete_data_in_text(),
);
$("#delete-realm-data-in").on("change", () => {
// If the user navigates away and back to the custom
// time input, we show a better value than "NaN" if
// the previous value was invalid.
if (!util.validate_custom_time_input(custom_deletion_time_input)) {
custom_deletion_time_input = 0;
}
settings_components.set_custom_time_inputs_visibility(
$("#delete-realm-data-in"),
custom_deletion_time_unit,
custom_deletion_time_input,
);
settings_components.set_time_input_formatted_text(
$("#delete-realm-data-in"),
delete_data_in_text(),
);
toggle_deactivate_submit_button();
});
$("#custom-deletion-time-input").on("keydown", (e) => {
if (e.key === "Enter") {
// Prevent submitting the realm deactivation form via Enter.
e.preventDefault();
return;
}
});
$("#custom-realm-deletion-time").on(
"input propertychange",
".custom-time-input-value, .custom-time-input-unit",
() => {
custom_deletion_time_input = util.check_time_input(
$<HTMLInputElement>("input#custom-deletion-time-input").val()!,
);
custom_deletion_time_unit = $<HTMLSelectOneElement>(
"select:not([multiple])#custom-deletion-time-unit",
).val()!;
settings_components.set_time_input_formatted_text(
$("#delete-realm-data-in"),
delete_data_in_text(),
);
toggle_deactivate_submit_button();
},
);
}
const all_delete_options = Object.values(settings_config.realm_deletion_in_values);
const valid_delete_options = all_delete_options.filter((option) =>
is_valid_time_period(option.value),
);
const time_unit_choices = [
settings_config.custom_time_unit_values.days,
settings_config.custom_time_unit_values.weeks,
];
const html_body = render_settings_deactivate_realm_modal({
delete_in_options: valid_delete_options,
custom_deletion_input_label: get_custom_deletion_input_text(),
time_choices: time_unit_choices,
});
dialog_widget.launch({ dialog_widget.launch({
html_heading: $t_html({defaultMessage: "Deactivate organization"}), html_heading: $t_html({defaultMessage: "Deactivate organization"}),
help_link: "/help/deactivate-your-organization", help_link: "/help/deactivate-your-organization",
html_body, html_body,
id: "deactivate-realm-user-modal",
on_click: do_deactivate_realm, on_click: do_deactivate_realm,
close_on_submit: false, close_on_submit: false,
focus_submit_on_open: true, focus_submit_on_open: true,
html_submit_button: $t_html({defaultMessage: "Confirm"}), html_submit_button: $t_html({defaultMessage: "Confirm"}),
post_render: deactivate_realm_modal_post_render,
}); });
} }

View File

@@ -398,6 +398,8 @@ export const realm_schema = z.object({
server_emoji_data_url: z.string(), server_emoji_data_url: z.string(),
server_inline_image_preview: z.boolean(), server_inline_image_preview: z.boolean(),
server_inline_url_embed_preview: z.boolean(), server_inline_url_embed_preview: z.boolean(),
server_max_deactivated_realm_deletion_days: z.nullable(z.number()),
server_min_deactivated_realm_deletion_days: z.nullable(z.number()),
server_jitsi_server_url: z.nullable(z.string()), server_jitsi_server_url: z.nullable(z.string()),
server_name_changes_disabled: z.boolean(), server_name_changes_disabled: z.boolean(),
server_needs_upgrade: z.boolean(), server_needs_upgrade: z.boolean(),

View File

@@ -1,2 +1,22 @@
<p>{{t "This action is permanent and cannot be undone. All users will permanently lose access to their Zulip accounts." }}</p> <form id="realm-data-deletion-form">
<p>{{t "Are you sure you want to deactivate this organization?"}}</p> <div class="input-group">
<label for="delete-realm-data-in" class="modal-field-label">{{t "After how much time should all data for this organization be permanently deleted (users, channels, messages, etc.)?" }}</label>
<select id="delete-realm-data-in" name="delete-realm-data-in" class="modal_select bootstrap-focus-style">
{{#each delete_in_options}}
<option {{#if this.default }}selected{{/if}} value="{{this.value}}">{{this.description}}</option>
{{/each}}
</select>
<p class="time-input-formatted-description"></p>
<div id="custom-realm-deletion-time" class="dependent-settings-block custom-time-input-container">
<label class="modal-field-label">{{custom_deletion_input_label}}</label>
<input id="custom-deletion-time-input" name="custom-deletion-time-input" class="custom-time-input-value inline-block modal_text_input" type="text" autocomplete="off" value="" maxlength="4"/>
<select id="custom-deletion-time-unit" name="custom-deletion-time-unit" class="custom-time-input-unit bootstrap-focus-style modal_select" >
{{#each time_choices}}
<option value="{{this.name}}">{{this.description}}</option>
{{/each}}
</select>
<p class="custom-time-input-formatted-description"></p>
</div>
</div>
</form>
<p>{{t "Are you sure you want to deactivate this organization? All users will lose access to their Zulip accounts." }}</p>

View File

@@ -1,3 +1,4 @@
import datetime
import logging import logging
import zoneinfo import zoneinfo
from email.headerregistry import Address from email.headerregistry import Address
@@ -16,6 +17,7 @@ from zerver.actions.user_groups import update_users_in_full_members_system_group
from zerver.actions.user_settings import do_delete_avatar_image from zerver.actions.user_settings import do_delete_avatar_image
from zerver.lib.exceptions import JsonableError from zerver.lib.exceptions import JsonableError
from zerver.lib.message import parse_message_time_limit_setting, update_first_visible_message_id from zerver.lib.message import parse_message_time_limit_setting, update_first_visible_message_id
from zerver.lib.queue import queue_json_publish_rollback_unsafe
from zerver.lib.retention import move_messages_to_archive from zerver.lib.retention import move_messages_to_archive
from zerver.lib.send_email import FromAddress, send_email, send_email_to_admins from zerver.lib.send_email import FromAddress, send_email, send_email_to_admins
from zerver.lib.sessions import delete_realm_user_sessions from zerver.lib.sessions import delete_realm_user_sessions
@@ -517,6 +519,7 @@ def do_deactivate_realm(
*, *,
acting_user: UserProfile | None, acting_user: UserProfile | None,
deactivation_reason: RealmDeactivationReasonType, deactivation_reason: RealmDeactivationReasonType,
deletion_delay_days: int | None = None,
email_owners: bool, email_owners: bool,
) -> None: ) -> None:
""" """
@@ -533,7 +536,13 @@ def do_deactivate_realm(
with transaction.atomic(durable=True): with transaction.atomic(durable=True):
realm.deactivated = True realm.deactivated = True
realm.save(update_fields=["deactivated"]) if deletion_delay_days is None:
realm.save(update_fields=["deactivated"])
else:
realm.scheduled_deletion_date = timezone_now() + datetime.timedelta(
days=deletion_delay_days
)
realm.save(update_fields=["scheduled_deletion_date", "deactivated"])
if settings.BILLING_ENABLED: if settings.BILLING_ENABLED:
billing_session = RealmBillingSession(user=acting_user, realm=realm) billing_session = RealmBillingSession(user=acting_user, realm=realm)
@@ -566,6 +575,13 @@ def do_deactivate_realm(
event = dict(type="realm", op="deactivated", realm_id=realm.id) event = dict(type="realm", op="deactivated", realm_id=realm.id)
send_event_on_commit(realm, event, active_user_ids(realm.id)) send_event_on_commit(realm, event, active_user_ids(realm.id))
if deletion_delay_days == 0:
event = {
"type": "scrub_deactivated_realm",
"realm_id": realm.id,
}
queue_json_publish_rollback_unsafe("deferred_work", event)
# Don't deactivate the users, as that would lose a lot of state if # Don't deactivate the users, as that would lose a lot of state if
# the realm needs to be reactivated, but do delete their sessions # the realm needs to be reactivated, but do delete their sessions
# so they get bumped to the login screen, where they'll get a # so they get bumped to the login screen, where they'll get a
@@ -590,8 +606,9 @@ def do_reactivate_realm(realm: Realm) -> None:
return return
realm.deactivated = False realm.deactivated = False
realm.scheduled_deletion_date = None
with transaction.atomic(durable=True): with transaction.atomic(durable=True):
realm.save(update_fields=["deactivated"]) realm.save(update_fields=["deactivated", "scheduled_deletion_date"])
event_time = timezone_now() event_time = timezone_now()
RealmAuditLog.objects.create( RealmAuditLog.objects.create(
@@ -636,6 +653,7 @@ def do_delete_all_realm_attachments(realm: Realm, *, batch_size: int = 1000) ->
obj_class._default_manager.filter(realm=realm).delete() obj_class._default_manager.filter(realm=realm).delete()
@transaction.atomic(durable=True)
def do_scrub_realm(realm: Realm, *, acting_user: UserProfile | None) -> None: def do_scrub_realm(realm: Realm, *, acting_user: UserProfile | None) -> None:
if settings.BILLING_ENABLED: if settings.BILLING_ENABLED:
from corporate.lib.stripe import RealmBillingSession from corporate.lib.stripe import RealmBillingSession
@@ -691,6 +709,20 @@ def do_scrub_realm(realm: Realm, *, acting_user: UserProfile | None) -> None:
acting_user=acting_user, acting_user=acting_user,
event_type=AuditLogEventType.REALM_SCRUBBED, event_type=AuditLogEventType.REALM_SCRUBBED,
) )
realm.scheduled_deletion_date = None
realm.save()
def scrub_deactivated_realm(realm_to_scrub: Realm) -> None:
if (
realm_to_scrub.scheduled_deletion_date is not None
and realm_to_scrub.scheduled_deletion_date <= timezone_now()
):
assert (
realm_to_scrub.deactivated
), "Non-deactivated realm unexpectedly scheduled for deletion."
do_scrub_realm(realm_to_scrub, acting_user=None)
logging.info("Scrubbed realm %s", realm_to_scrub.id)
@transaction.atomic(durable=True) @transaction.atomic(durable=True)

View File

@@ -471,6 +471,14 @@ def fetch_initial_state_data(
) )
state["server_supported_permission_settings"] = get_server_supported_permission_settings() state["server_supported_permission_settings"] = get_server_supported_permission_settings()
state["server_min_deactivated_realm_deletion_days"] = (
settings.MIN_DEACTIVATED_REALM_DELETION_DAYS
)
state["server_max_deactivated_realm_deletion_days"] = (
settings.MAX_DEACTIVATED_REALM_DELETION_DAYS
)
if want("realm_user_settings_defaults"): if want("realm_user_settings_defaults"):
realm_user_default = RealmUserDefault.objects.get(realm=realm) realm_user_default = RealmUserDefault.objects.get(realm=realm)
state["realm_user_settings_defaults"] = {} state["realm_user_settings_defaults"] = {}

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.0.9 on 2024-10-12 07:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zerver", "0642_realm_moderation_request_channel"),
]
operations = [
migrations.AddField(
model_name="realm",
name="scheduled_deletion_date",
field=models.DateTimeField(db_index=True, default=None, null=True),
),
]

View File

@@ -184,6 +184,7 @@ class Realm(models.Model): # type: ignore[django-manager-missing] # django-stub
push_notifications_enabled_end_timestamp = models.DateTimeField(default=None, null=True) push_notifications_enabled_end_timestamp = models.DateTimeField(default=None, null=True)
date_created = models.DateTimeField(default=timezone_now) date_created = models.DateTimeField(default=timezone_now)
scheduled_deletion_date = models.DateTimeField(default=None, db_index=True, null=True)
demo_organization_scheduled_deletion_date = models.DateTimeField(default=None, null=True) demo_organization_scheduled_deletion_date = models.DateTimeField(default=None, null=True)
deactivated = models.BooleanField(default=False) deactivated = models.BooleanField(default=False)

View File

@@ -14388,6 +14388,30 @@ paths:
**Changes**: New in Zulip 4.0 (feature level 53). Previously, **Changes**: New in Zulip 4.0 (feature level 53). Previously,
this property always had a value of 10000. this property always had a value of 10000.
server_min_deactivated_realm_deletion_days:
type: integer
nullable: true
description: |
Present if `realm` is present in `fetch_event_types`.
The minimum permitted number of days before full data deletion
(users, channels, messages, etc.) of a deactivated organization.
If `null`, then a deactivated organization's data can be
deleted immediately.
**Changes**: New in Zulip 10.0 (feature level 332)
server_max_deactivated_realm_deletion_days:
type: integer
nullable: true
description: |
Present if `realm` is present in `fetch_event_types`.
The maximum permitted number of days before full data deletion
(users, channels, messages, etc.) of a deactivated organization.
If `null`, then a deactivated organization's data can be
retained indefinitely.
**Changes**: New in Zulip 10.0 (feature level 332).
server_presence_ping_interval_seconds: server_presence_ping_interval_seconds:
type: integer type: integer
description: | description: |

View File

@@ -222,6 +222,8 @@ class HomeTest(ZulipTestCase):
"server_generation", "server_generation",
"server_inline_image_preview", "server_inline_image_preview",
"server_inline_url_embed_preview", "server_inline_url_embed_preview",
"server_max_deactivated_realm_deletion_days",
"server_min_deactivated_realm_deletion_days",
"server_jitsi_server_url", "server_jitsi_server_url",
"server_name_changes_disabled", "server_name_changes_disabled",
"server_needs_upgrade", "server_needs_upgrade",

View File

@@ -8,6 +8,7 @@ from typing import Any
from unittest import mock, skipUnless from unittest import mock, skipUnless
import orjson import orjson
import time_machine
from django.conf import settings from django.conf import settings
from django.core import mail from django.core import mail
from django.test import override_settings from django.test import override_settings
@@ -36,6 +37,7 @@ from zerver.actions.realm_settings import (
do_set_realm_authentication_methods, do_set_realm_authentication_methods,
do_set_realm_property, do_set_realm_property,
do_set_realm_user_default_setting, do_set_realm_user_default_setting,
scrub_deactivated_realm,
) )
from zerver.actions.streams import do_deactivate_stream, merge_streams from zerver.actions.streams import do_deactivate_stream, merge_streams
from zerver.actions.user_groups import check_add_user_group from zerver.actions.user_groups import check_add_user_group
@@ -423,12 +425,17 @@ class RealmTest(ZulipTestCase):
def test_do_reactivate_realm(self) -> None: def test_do_reactivate_realm(self) -> None:
realm = get_realm("zulip") realm = get_realm("zulip")
do_deactivate_realm( do_deactivate_realm(
realm, acting_user=None, deactivation_reason="owner_request", email_owners=False realm,
acting_user=None,
deactivation_reason="owner_request",
email_owners=False,
deletion_delay_days=15,
) )
self.assertTrue(realm.deactivated) self.assertTrue(realm.deactivated)
do_reactivate_realm(realm) do_reactivate_realm(realm)
self.assertFalse(realm.deactivated) self.assertFalse(realm.deactivated)
self.assertEqual(realm.scheduled_deletion_date, None)
log_entry = RealmAuditLog.objects.last() log_entry = RealmAuditLog.objects.last()
assert log_entry is not None assert log_entry is not None
@@ -998,6 +1005,99 @@ class RealmTest(ZulipTestCase):
result = self.client_patch("/json/realm", req) result = self.client_patch("/json/realm", req)
self.assert_json_success(result) self.assert_json_success(result)
def test_data_deletion_schedule_when_deactivating_realm(self) -> None:
self.login("desdemona")
# settings.MIN_DEACTIVATED_REALM_DELETION_DAYS have default value 14.
# So minimum 14 days should be given for data deletion.
result = self.client_post("/json/realm/deactivate", {"deletion_delay_days": 12})
self.assert_json_error(result, "Data deletion time must be at least 14 days in the future.")
result = self.client_post("/json/realm/deactivate", {"deletion_delay_days": 17})
self.assert_json_success(result)
do_reactivate_realm(get_realm("zulip"))
with self.settings(MIN_DEACTIVATED_REALM_DELETION_DAYS=None):
self.login("desdemona")
result = self.client_post("/json/realm/deactivate", {"deletion_delay_days": 12})
self.assert_json_success(result)
do_reactivate_realm(get_realm("zulip"))
with self.settings(MAX_DEACTIVATED_REALM_DELETION_DAYS=30):
self.login("desdemona")
# None value to deletion_delay_days means data will be never deleted.
result = self.client_post(
"/json/realm/deactivate", {"deletion_delay_days": orjson.dumps(None).decode()}
)
self.assert_json_error(
result,
"Data deletion time must be at most 30 days in the future.",
)
result = self.client_post("/json/realm/deactivate", {"deletion_delay_days": 40})
self.assert_json_error(
result,
"Data deletion time must be at most 30 days in the future.",
)
result = self.client_post("/json/realm/deactivate", {"deletion_delay_days": 25})
self.assert_json_success(result)
def test_scrub_deactivated_realms(self) -> None:
zulip = get_realm("zulip")
zephyr = get_realm("zephyr")
lear = get_realm("lear")
do_deactivate_realm(
zephyr,
acting_user=None,
deletion_delay_days=3,
deactivation_reason="owner_request",
email_owners=False,
)
self.assertTrue(zephyr.deactivated)
do_deactivate_realm(
zulip,
acting_user=None,
deletion_delay_days=None,
deactivation_reason="owner_request",
email_owners=False,
)
self.assertTrue(zulip.deactivated)
with mock.patch("zerver.actions.realm_settings.do_scrub_realm") as mock_scrub_realm:
scrub_deactivated_realm(zephyr)
scrub_deactivated_realm(zulip)
mock_scrub_realm.assert_not_called()
with (
mock.patch("zerver.actions.realm_settings.do_scrub_realm") as mock_scrub_realm,
self.assertLogs(level="INFO"),
):
do_deactivate_realm(
lear,
acting_user=None,
deletion_delay_days=0,
deactivation_reason="owner_request",
email_owners=False,
)
self.assertTrue(lear.deactivated)
mock_scrub_realm.assert_called_once_with(lear, acting_user=None)
with (
time_machine.travel(timezone_now() + timedelta(days=4), tick=False),
mock.patch("zerver.actions.realm_settings.do_scrub_realm") as mock_scrub_realm,
self.assertLogs(level="INFO"),
):
scrub_deactivated_realm(get_realm("zephyr"))
scrub_deactivated_realm(get_realm("zulip"))
mock_scrub_realm.assert_called_once_with(zephyr, acting_user=None)
def test_initial_plan_type(self) -> None: def test_initial_plan_type(self) -> None:
with self.settings(BILLING_ENABLED=True): with self.settings(BILLING_ENABLED=True):
self.assertEqual(do_create_realm("hosted", "hosted").plan_type, Realm.PLAN_TYPE_LIMITED) self.assertEqual(do_create_realm("hosted", "hosted").plan_type, Realm.PLAN_TYPE_LIMITED)

View File

@@ -1,6 +1,7 @@
from collections.abc import Mapping from collections.abc import Mapping
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import transaction from django.db import transaction
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
@@ -34,11 +35,7 @@ from zerver.lib.i18n import get_available_language_codes
from zerver.lib.response import json_success from zerver.lib.response import json_success
from zerver.lib.retention import parse_message_retention_days from zerver.lib.retention import parse_message_retention_days
from zerver.lib.streams import access_stream_by_id from zerver.lib.streams import access_stream_by_id
from zerver.lib.typed_endpoint import ( from zerver.lib.typed_endpoint import ApiParamConfig, typed_endpoint
ApiParamConfig,
typed_endpoint,
typed_endpoint_without_parameters,
)
from zerver.lib.typed_endpoint_validators import check_int_in_validator, check_string_in_validator from zerver.lib.typed_endpoint_validators import check_int_in_validator, check_string_in_validator
from zerver.lib.user_groups import ( from zerver.lib.user_groups import (
GroupSettingChangeRequest, GroupSettingChangeRequest,
@@ -498,11 +495,38 @@ def update_realm(
@require_realm_owner @require_realm_owner
@typed_endpoint_without_parameters @typed_endpoint
def deactivate_realm(request: HttpRequest, user: UserProfile) -> HttpResponse: def deactivate_realm(
request: HttpRequest, user: UserProfile, *, deletion_delay_days: Json[int | None] = None
) -> HttpResponse:
if settings.MAX_DEACTIVATED_REALM_DELETION_DAYS is not None and (
deletion_delay_days is None
or deletion_delay_days > settings.MAX_DEACTIVATED_REALM_DELETION_DAYS
):
raise JsonableError(
_("Data deletion time must be at most {max_allowed_days} days in the future.").format(
max_allowed_days=settings.MAX_DEACTIVATED_REALM_DELETION_DAYS,
)
)
if (
settings.MIN_DEACTIVATED_REALM_DELETION_DAYS is not None
and deletion_delay_days is not None
and deletion_delay_days < settings.MIN_DEACTIVATED_REALM_DELETION_DAYS
):
raise JsonableError(
_("Data deletion time must be at least {min_allowed_days} days in the future.").format(
min_allowed_days=settings.MIN_DEACTIVATED_REALM_DELETION_DAYS,
)
)
realm = user.realm realm = user.realm
do_deactivate_realm( do_deactivate_realm(
realm, acting_user=user, deactivation_reason="owner_request", email_owners=True realm,
acting_user=user,
deactivation_reason="owner_request",
email_owners=True,
deletion_delay_days=deletion_delay_days,
) )
return json_success(request) return json_success(request)

View File

@@ -15,6 +15,7 @@ from typing_extensions import override
from zerver.actions.message_flags import do_mark_stream_messages_as_read from zerver.actions.message_flags import do_mark_stream_messages_as_read
from zerver.actions.message_send import internal_send_private_message from zerver.actions.message_send import internal_send_private_message
from zerver.actions.realm_export import notify_realm_export from zerver.actions.realm_export import notify_realm_export
from zerver.actions.realm_settings import scrub_deactivated_realm
from zerver.lib.export import export_realm_wrapper from zerver.lib.export import export_realm_wrapper
from zerver.lib.push_notifications import clear_push_device_tokens from zerver.lib.push_notifications import clear_push_device_tokens
from zerver.lib.queue import queue_json_publish_rollback_unsafe, retry_event from zerver.lib.queue import queue_json_publish_rollback_unsafe, retry_event
@@ -228,6 +229,13 @@ class DeferredWorker(QueueProcessingWorker):
realm_id = event["realm_id"] realm_id = event["realm_id"]
logger.info("Updating push bouncer with metadata on behalf of realm %s", realm_id) logger.info("Updating push bouncer with metadata on behalf of realm %s", realm_id)
send_server_data_to_push_bouncer(consider_usage_statistics=False) send_server_data_to_push_bouncer(consider_usage_statistics=False)
elif event["type"] == "scrub_deactivated_realm":
realms_to_scrub = Realm.objects.filter(
deactivated=True,
scheduled_deletion_date__lte=timezone_now(),
)
for realm in realms_to_scrub:
scrub_deactivated_realm(realm)
end = time.time() end = time.time()
logger.info( logger.info(

View File

@@ -678,3 +678,10 @@ RESOLVE_TOPIC_UNDO_GRACE_PERIOD_SECONDS = 60
# For realm imports during registration, maximum size of file # For realm imports during registration, maximum size of file
# that can be uploaded. # that can be uploaded.
MAX_WEB_DATA_IMPORT_SIZE_MB = 1024 MAX_WEB_DATA_IMPORT_SIZE_MB = 1024
# Minimum and maximum permitted number of days before full data
# deletion when deactivating an organization. A nonzero minimum helps
# protect against a compromised administrator account being used to
# delete an active organization.
MIN_DEACTIVATED_REALM_DELETION_DAYS: int | None = 14
MAX_DEACTIVATED_REALM_DELETION_DAYS: int | None = None