settings: Add can_set_delete_message_policy_group setting.

Adds new organization setting `can_set_delete_message_policy_group`
for defining who can set per-channel message delete permissions.

Fixes #34214.
This commit is contained in:
Vector73
2025-07-17 09:54:52 +00:00
committed by Tim Abbott
parent 97a43fa6b6
commit a02614204a
24 changed files with 382 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
* `PATCH /realm`, [`POST /register`](/api/register-queue),
[`GET /events`](/api/get-events): Added `can_set_delete_message_policy_group`
realm setting, which is a [group-setting value](/api/group-setting-values)
describing the set of users with permission to change per-channel
`can_delete_any_message_group` and `can_delete_own_message_group` settings.

View File

@@ -60,6 +60,7 @@ export const realm_group_setting_name_schema = z.enum([
"can_move_messages_between_channels_group",
"can_move_messages_between_topics_group",
"can_resolve_topics_group",
"can_set_delete_message_policy_group",
"can_set_topics_policy_group",
"can_summarize_topics_group",
"create_multiuse_invite_group",

View File

@@ -250,6 +250,7 @@ export function dispatch_normal_event(event) {
can_move_messages_between_channels_group: noop,
can_move_messages_between_topics_group: noop,
can_resolve_topics_group: noop,
can_set_delete_message_policy_group: noop,
can_set_topics_policy_group: noop,
can_summarize_topics_group: noop,
create_multiuse_invite_group: noop,

View File

@@ -856,6 +856,7 @@ export function check_realm_settings_property_changed(elem: HTMLElement): boolea
case "realm_can_move_messages_between_channels_group":
case "realm_can_move_messages_between_topics_group":
case "realm_can_resolve_topics_group":
case "realm_can_set_delete_message_policy_group":
case "realm_can_set_topics_policy_group":
case "realm_can_summarize_topics_group":
case "realm_create_multiuse_invite_group":
@@ -1119,6 +1120,7 @@ export function populate_data_for_realm_settings_request(
"can_move_messages_between_channels_group",
"can_move_messages_between_topics_group",
"can_resolve_topics_group",
"can_set_delete_message_policy_group",
"can_set_topics_policy_group",
"can_summarize_topics_group",
"create_multiuse_invite_group",
@@ -1628,6 +1630,7 @@ export const group_setting_widget_map = new Map<string, GroupSettingPillContaine
["realm_can_move_messages_between_channels_group", null],
["realm_can_move_messages_between_topics_group", null],
["realm_can_resolve_topics_group", null],
["realm_can_set_delete_message_policy_group", null],
["realm_can_set_topics_policy_group", null],
["realm_can_summarize_topics_group", null],
["realm_create_multiuse_invite_group", null],

View File

@@ -754,6 +754,10 @@ export const all_group_setting_labels = {
can_mention_many_users_group: $t({
defaultMessage: "Who can notify a large number of users with a wildcard mention",
}),
can_set_delete_message_policy_group: $t({
defaultMessage:
"Who can allow users to delete messages in channels they administer",
}),
can_set_topics_policy_group: new Handlebars.SafeString(
$t_html({
defaultMessage:
@@ -817,6 +821,7 @@ export const realm_group_permission_settings: {
"can_create_private_channel_group",
"can_add_subscribers_group",
"can_mention_many_users_group",
"can_set_delete_message_policy_group",
"can_set_topics_policy_group",
],
},

View File

@@ -521,6 +521,7 @@ export function discard_realm_property_element_changes(elem: HTMLElement): void
case "realm_can_move_messages_between_channels_group":
case "realm_can_move_messages_between_topics_group":
case "realm_can_resolve_topics_group":
case "realm_can_set_delete_message_policy_group":
case "realm_can_set_topics_policy_group":
case "realm_can_summarize_topics_group":
case "realm_create_multiuse_invite_group":

View File

@@ -333,6 +333,7 @@ export const realm_schema = z.object({
realm_can_move_messages_between_channels_group: group_setting_value_schema,
realm_can_move_messages_between_topics_group: group_setting_value_schema,
realm_can_resolve_topics_group: group_setting_value_schema,
realm_can_set_delete_message_policy_group: group_setting_value_schema,
realm_can_set_topics_policy_group: group_setting_value_schema,
realm_can_summarize_topics_group: group_setting_value_schema,
realm_create_multiuse_invite_group: group_setting_value_schema,

View File

@@ -549,6 +549,15 @@ export function show_new_stream_modal(): void {
$("#id_new_topics_policy").prop("disabled", true);
}
if (!stream_data.user_can_set_delete_message_policy()) {
settings_components.disable_group_permission_setting(
$("#id_new_can_delete_any_message_group"),
);
settings_components.disable_group_permission_setting(
$("#id_new_can_delete_own_message_group"),
);
}
// set default state for "announce stream" and "default stream" option.
$("#stream_creation_form .default-stream input").prop("checked", false);
update_announce_stream_state();

View File

@@ -635,6 +635,24 @@ export function can_administer_channel(sub: StreamSubscription): boolean {
);
}
export function user_can_set_delete_message_policy(sub?: StreamSubscription): boolean {
if (current_user.is_admin) {
return true;
}
const user_can_set_delete_message_policy = settings_data.user_has_permission_for_group_setting(
realm.realm_can_set_delete_message_policy_group,
"can_set_delete_message_policy_group",
"realm",
);
// This handles the case when the stream is being created.
if (sub === undefined) {
return user_can_set_delete_message_policy;
}
return user_can_set_delete_message_policy && can_administer_channel(sub);
}
export function user_can_set_topics_policy(sub?: StreamSubscription): boolean {
if (current_user.is_admin) {
return true;

View File

@@ -405,6 +405,11 @@ export function enable_or_disable_permission_settings_in_edit_panel(
if (!stream_data.user_can_set_topics_policy(sub)) {
$stream_settings.find("#id_topics_policy").prop("disabled", true);
}
if (!stream_data.user_can_set_delete_message_policy()) {
settings_components.disable_group_permission_setting($("#id_can_delete_any_message_group"));
settings_components.disable_group_permission_setting($("#id_can_delete_own_message_group"));
}
}
export function update_announce_stream_option(): void {

View File

@@ -289,6 +289,10 @@
<span class="time-unit-text">{{t "{realm_message_content_delete_limit_minutes, plural, one {minute} other {minutes}}"}}</span>
</div>
</div>
{{> group_setting_value_pill_input
setting_name="realm_can_set_delete_message_policy_group"
label=group_setting_labels.can_set_delete_message_policy_group}}
</div>
</div>

View File

@@ -1799,6 +1799,41 @@ test("user_can_set_topics_policy", ({override}) => {
assert.equal(stream_data.user_can_set_topics_policy(sub), false);
});
test("user_can_set_delete_message_policy", ({override}) => {
const sub = {
name: "Denmark",
subscribed: true,
color: "red",
stream_id: 1,
can_add_subscribers_group: admins_group.id,
can_administer_channel_group: nobody_group.id,
can_remove_subscribers_group: admins_group.id,
};
stream_data.add_sub(sub);
override(realm, "realm_can_set_delete_message_policy_group", nobody_group.id);
// Admins can always change per-channel delete_message policy.
initialize_and_override_current_user(admin_user_id, override);
override(current_user, "is_admin", true);
assert.equal(stream_data.user_can_set_delete_message_policy(sub), true);
initialize_and_override_current_user(moderator_user_id, override);
override(current_user, "is_admin", false);
assert.equal(stream_data.user_can_set_delete_message_policy(sub), false);
// Not allowed as user not in can_administer_channel_group.
override(realm, "realm_can_set_delete_message_policy_group", everyone_group.id);
assert.equal(stream_data.user_can_set_delete_message_policy(sub), false);
sub.can_administer_channel_group = moderators_group.id;
assert.equal(stream_data.user_can_set_delete_message_policy(sub), true);
// Only realm_can_set_delete_message_policy_group is checked if sub is not provided.
assert.equal(stream_data.user_can_set_delete_message_policy(), true);
override(realm, "realm_can_set_delete_message_policy_group", nobody_group.id);
assert.equal(stream_data.user_can_set_delete_message_policy(sub), false);
});
test("options for dropdown widget", () => {
const denmark = {
subscribed: true,

View File

@@ -599,6 +599,7 @@ class GroupSettingUpdateData(GroupSettingUpdateDataCore):
can_move_messages_between_channels_group: int | UserGroupMembersDict | None = None
can_move_messages_between_topics_group: int | UserGroupMembersDict | None = None
can_resolve_topics_group: int | UserGroupMembersDict | None = None
can_set_delete_message_policy_group: int | UserGroupMembersDict | None = None
can_set_topics_policy_group: int | UserGroupMembersDict | None = None
can_summarize_topics_group: int | UserGroupMembersDict | None = None
direct_message_initiator_group: int | UserGroupMembersDict | None = None

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.3 on 2025-06-26 12:52
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zerver", "0736_alter_stream_can_delete_own_message_group"),
]
operations = [
migrations.AddField(
model_name="realm",
name="can_set_delete_message_policy_group",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.RESTRICT,
related_name="+",
to="zerver.usergroup",
),
),
]

View File

@@ -0,0 +1,35 @@
from django.db import migrations
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.migrations.state import StateApps
from django.db.models import OuterRef
def set_default_value_for_can_set_delete_message_policy_group(
apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
) -> None:
Realm = apps.get_model("zerver", "Realm")
NamedUserGroup = apps.get_model("zerver", "NamedUserGroup")
MODERATORS_GROUP_NAME = "role:moderators"
Realm.objects.filter(can_set_delete_message_policy_group=None).update(
can_set_delete_message_policy_group=NamedUserGroup.objects.filter(
name=MODERATORS_GROUP_NAME, realm=OuterRef("id"), is_system_group=True
).values("pk")
)
class Migration(migrations.Migration):
atomic = False
dependencies = [
("zerver", "0737_realm_can_set_delete_message_policy_group"),
]
operations = [
migrations.RunPython(
set_default_value_for_can_set_delete_message_policy_group,
elidable=True,
reverse_code=migrations.RunPython.noop,
)
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.2.3 on 2025-06-26 12:55
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zerver", "0738_set_default_value_for_can_set_delete_message_policy_group"),
]
operations = [
migrations.AlterField(
model_name="realm",
name="can_set_delete_message_policy_group",
field=models.ForeignKey(
on_delete=django.db.models.deletion.RESTRICT,
related_name="+",
to="zerver.usergroup",
),
),
]

View File

@@ -390,6 +390,11 @@ class Realm(models.Model):
"UserGroup", on_delete=models.RESTRICT, related_name="+"
)
# UserGroup which is allowed to set per-channel delete permissions setting.
can_set_delete_message_policy_group = models.ForeignKey(
"UserGroup", on_delete=models.RESTRICT, related_name="+"
)
# UserGroup which is allowed to set per-channel `topics_policy` setting.
can_set_topics_policy_group = models.ForeignKey(
"UserGroup", on_delete=models.RESTRICT, related_name="+"
@@ -836,6 +841,11 @@ class Realm(models.Model):
allow_everyone_group=True,
default_group_name=SystemGroups.EVERYONE,
),
can_set_delete_message_policy_group=GroupPermissionSetting(
allow_nobody_group=True,
allow_everyone_group=False,
default_group_name=SystemGroups.MODERATORS,
),
can_set_topics_policy_group=GroupPermissionSetting(
allow_nobody_group=True,
allow_everyone_group=True,

View File

@@ -922,6 +922,9 @@ class UserProfile(AbstractBaseUser, PermissionsMixin, UserBaseSettings):
def can_delete_own_message(self) -> bool:
return self.has_permission("can_delete_own_message_group")
def can_set_delete_message_policy(self) -> bool:
return self.is_realm_admin or self.has_permission("can_set_delete_message_policy_group")
def can_set_topics_policy(self) -> bool:
return self.is_realm_admin or self.has_permission("can_set_topics_policy_group")

View File

@@ -4848,6 +4848,20 @@ paths:
setting controlled this permission; `true` corresponded to `Everyone`, and
`false` to `Admins`.
- $ref: "#/components/schemas/GroupSettingValue"
can_set_delete_message_policy_group:
allOf:
- description: |
A [group-setting value](/api/group-setting-values) defining the set of
users who have permission to change per-channel `can_delete_any_message_group`
and `can_delete_own_message_group` permission settings. Note that the user
must be a member of both this group and the `can_administer_channel_group`
of the channel whose message delete settings they want to change.
Organization administrators can always change these settings of
every channel.
**Changes**: New in Zulip 11.0 (feature level ZF-4ce648).
- $ref: "#/components/schemas/GroupSettingValue"
can_set_topics_policy_group:
allOf:
- description: |
@@ -18312,6 +18326,22 @@ paths:
setting controlled this permission; `true` corresponded to `Everyone`, and
`false` to `Admins`.
- $ref: "#/components/schemas/GroupSettingValue"
realm_can_set_delete_message_policy_group:
allOf:
- description: |
Present if `realm` is present in `fetch_event_types`.
A [group-setting value](/api/group-setting-values) defining the set of
users who have permission to change per-channel `can_delete_any_message_group`
and `can_delete_own_message_group` permission settings. Note that the user
must be a member of both this group and the `can_administer_channel_group`
of the channel whose message delete settings they want to change.
Organization administrators can always change these settings of
every channel.
**Changes**: New in Zulip 11.0 (feature level ZF-4ce648).
- $ref: "#/components/schemas/GroupSettingValue"
realm_can_set_topics_policy_group:
allOf:
- description: |

View File

@@ -1,7 +1,10 @@
import orjson
from zerver.actions.channel_folders import check_add_channel_folder
from zerver.actions.realm_settings import do_change_realm_plan_type
from zerver.actions.realm_settings import (
do_change_realm_permission_group_setting,
do_change_realm_plan_type,
)
from zerver.actions.user_groups import check_add_user_group
from zerver.actions.users import do_change_user_role
from zerver.lib.default_streams import get_default_stream_ids_for_realm
@@ -563,6 +566,17 @@ class TestCreateStreams(ZulipTestCase):
)
def test_permission_settings_on_stream_creation(self) -> None:
realm = get_realm("zulip")
members_system_group = NamedUserGroup.objects.get(
name=SystemGroups.MEMBERS, realm=realm, is_system_group=True
)
do_change_realm_permission_group_setting(
realm,
"can_set_delete_message_policy_group",
members_system_group,
acting_user=None,
)
for setting_name in Stream.stream_permission_group_settings:
self.do_test_permission_setting_on_stream_creation(setting_name)

View File

@@ -1250,6 +1250,13 @@ class ChannelAdministerPermissionTest(ZulipTestCase):
hamlet = self.example_user("hamlet")
prospero = self.example_user("prospero")
do_change_realm_permission_group_setting(
hamlet.realm,
"can_set_delete_message_policy_group",
self.members_group,
acting_user=None,
)
for setting_name in Stream.stream_permission_group_settings:
self.do_test_updating_channel_group_settings(setting_name)
@@ -1333,3 +1340,136 @@ class ChannelAdministerPermissionTest(ZulipTestCase):
check_channel_topics_policy_update(self.admin)
check_channel_topics_policy_update(self.moderator)
check_channel_topics_policy_update(hamlet)
def test_can_set_delete_message_policy_group(self) -> None:
user = self.example_user("hamlet")
iago = self.example_user("iago")
realm = user.realm
stream = get_stream("Verona", realm)
owners_system_group = NamedUserGroup.objects.get(
realm=realm, name=SystemGroups.OWNERS, is_system_group=True
)
moderators_system_group = NamedUserGroup.objects.get(
realm=realm, name=SystemGroups.MODERATORS, is_system_group=True
)
members_system_group = NamedUserGroup.objects.get(
realm=realm, name=SystemGroups.MEMBERS, is_system_group=True
)
do_change_realm_permission_group_setting(
realm,
"can_set_delete_message_policy_group",
moderators_system_group,
acting_user=None,
)
do_change_stream_group_based_setting(
stream, "can_administer_channel_group", members_system_group, acting_user=iago
)
# Only moderators can change channel-level delete permissions.
# Hamlet is not a moderator.
subscriptions = [{"name": "new_test_stream"}]
result = self.subscribe_via_post(
user,
subscriptions,
subdomain="zulip",
extra_post_data={
"can_delete_any_message_group": orjson.dumps(owners_system_group.id).decode()
},
allow_fail=True,
)
self.assert_json_error(result, "Insufficient permission")
result = self.subscribe_via_post(
user,
subscriptions,
subdomain="zulip",
extra_post_data={
"can_delete_own_message_group": orjson.dumps(owners_system_group.id).decode()
},
allow_fail=True,
)
self.assert_json_error(result, "Insufficient permission")
self.login("hamlet")
result = self.client_patch(
f"/json/streams/{stream.id}",
{
"can_delete_any_message_group": orjson.dumps(
{
"new": {
"direct_members": [user.id],
"direct_subgroups": [
owners_system_group.id,
moderators_system_group.id,
],
}
}
).decode(),
"can_delete_own_message_group": orjson.dumps(
{
"new": {
"direct_members": [user.id],
"direct_subgroups": [
owners_system_group.id,
moderators_system_group.id,
],
}
}
).decode(),
},
)
self.assert_json_error(result, "Insufficient permission")
moderator = self.example_user("shiva")
# Shiva is a moderator.
result = self.subscribe_via_post(
moderator,
subscriptions,
subdomain="zulip",
extra_post_data={
"can_delete_any_message_group": orjson.dumps(owners_system_group.id).decode()
},
allow_fail=True,
)
self.assert_json_success(result)
result = self.subscribe_via_post(
moderator,
subscriptions,
subdomain="zulip",
extra_post_data={
"can_delete_own_message_group": orjson.dumps(owners_system_group.id).decode()
},
allow_fail=True,
)
self.assert_json_success(result)
self.login("shiva")
result = self.client_patch(
f"/json/streams/{stream.id}",
{
"can_delete_any_message_group": orjson.dumps(
{
"new": {
"direct_members": [user.id],
"direct_subgroups": [
owners_system_group.id,
moderators_system_group.id,
],
}
}
).decode(),
"can_delete_own_message_group": orjson.dumps(
{
"new": {
"direct_members": [user.id],
"direct_subgroups": [
owners_system_group.id,
moderators_system_group.id,
],
}
}
).decode(),
},
)
self.assert_json_success(result)

View File

@@ -144,6 +144,7 @@ class HomeTest(ZulipTestCase):
"realm_can_move_messages_between_channels_group",
"realm_can_move_messages_between_topics_group",
"realm_can_resolve_topics_group",
"realm_can_set_delete_message_policy_group",
"realm_can_set_topics_policy_group",
"realm_can_summarize_topics_group",
"realm_create_multiuse_invite_group",

View File

@@ -115,6 +115,7 @@ def update_realm(
can_move_messages_between_channels_group: Json[GroupSettingChangeRequest] | None = None,
can_move_messages_between_topics_group: Json[GroupSettingChangeRequest] | None = None,
can_resolve_topics_group: Json[GroupSettingChangeRequest] | None = None,
can_set_delete_message_policy_group: Json[GroupSettingChangeRequest] | None = None,
can_set_topics_policy_group: Json[GroupSettingChangeRequest] | None = None,
can_summarize_topics_group: Json[GroupSettingChangeRequest] | None = None,
create_multiuse_invite_group: Json[GroupSettingChangeRequest] | None = None,

View File

@@ -444,6 +444,11 @@ def update_stream_backend(
if is_archived is not None and not is_archived:
do_unarchive_stream(stream, stream.name, acting_user=None)
if (
can_delete_any_message_group is not None or can_delete_own_message_group is not None
) and not user_profile.can_set_delete_message_policy():
raise JsonableError(_("Insufficient permission"))
if description is not None:
do_change_stream_description(stream, description, acting_user=user_profile)
if new_name is not None:
@@ -668,6 +673,14 @@ def access_requested_group_permissions(
setting_name=setting_name,
permission_configuration=permission_configuration,
)
if (
setting_name in ["can_delete_any_message_group", "can_delete_own_message_group"]
and group_settings_map[setting_name].id
!= system_groups_name_dict[SystemGroups.NOBODY].id
and not user_profile.can_set_delete_message_policy()
):
raise JsonableError(_("Insufficient permission"))
if not isinstance(setting_value, int):
anonymous_group_membership[group_settings_map[setting_name].id] = setting_value
else: