mirror of
https://github.com/zulip/zulip.git
synced 2025-11-01 12:33:40 +00:00
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:
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]));
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
if deletion_delay_days is None:
|
||||||
realm.save(update_fields=["deactivated"])
|
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)
|
||||||
|
|||||||
@@ -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"] = {}
|
||||||
|
|||||||
17
zerver/migrations/0643_realm_scheduled_deletion_date.py
Normal file
17
zerver/migrations/0643_realm_scheduled_deletion_date.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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: |
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user