From a55ee823eae45383cccba6dc0dd0f466a53bf92b Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 16 Sep 2025 11:11:41 -0700 Subject: [PATCH 1/7] message test: Vary user role in pre-291 selfCanDeleteMessage tests And we'll use the new selfUserRole param for testing #1850, coming up. --- test/model/message_test.dart | 41 ++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 7dbf28a47d..84441b616f 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -868,7 +868,7 @@ void main() { group('selfCanDeleteMessage', () { /// Call the method, with setup from [params]. Future evaluate(CanDeleteMessageParams params) async { - final selfUser = eg.selfUser; + final selfUser = eg.user(role: params.selfUserRole); final botUserOwnedBySelf = eg.user(isBot: true, botOwnerId: selfUser.userId); final botUserNotOwnedBySelf = eg.user(isBot: true, botOwnerId: eg.otherUser.userId); @@ -1127,7 +1127,7 @@ void main() { // doesn't exist, so we follow realmDeleteOwnMessagePolicy instead, // and we don't error. - test('allowed', () async { + test('allowed (permissive policy, low role)', () async { check(await evaluate( CanDeleteMessageParams.pre291( senderConfig: CanDeleteMessageSenderConfig.self, @@ -1135,6 +1135,27 @@ void main() { inRealmCanDeleteAnyMessageGroup: false, isChannelArchived: false, realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.everyone, + selfUserRole: UserRole.member, + ))) + ..equals(await evaluate( + CanDeleteMessageParams.pre407( + senderConfig: CanDeleteMessageSenderConfig.self, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: false, + inRealmCanDeleteOwnMessageGroup: true, + isChannelArchived: false))) + ..isTrue(); + }); + + test('allowed (strict policy, high role)', () async { + check(await evaluate( + CanDeleteMessageParams.pre291( + senderConfig: CanDeleteMessageSenderConfig.self, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: false, + isChannelArchived: false, + realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.admins, + selfUserRole: UserRole.administrator, ))) ..equals(await evaluate( CanDeleteMessageParams.pre407( @@ -1154,6 +1175,7 @@ void main() { inRealmCanDeleteAnyMessageGroup: false, isChannelArchived: false, realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.admins, + selfUserRole: UserRole.moderator, )))..equals(await evaluate( CanDeleteMessageParams.pre407( senderConfig: CanDeleteMessageSenderConfig.self, @@ -1177,13 +1199,15 @@ void main() { timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, isChannelArchived: false, realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.everyone, + selfUserRole: UserRole.member, )))..equals(await evaluate( CanDeleteMessageParams.pre291( senderConfig: CanDeleteMessageSenderConfig.otherHuman, timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, inRealmCanDeleteAnyMessageGroup: false, isChannelArchived: false, - realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.everyone))) + realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.everyone, + selfUserRole: UserRole.member))) ..isFalse(); }); }); @@ -2006,6 +2030,7 @@ class CanDeleteMessageParams { final bool? inChannelCanDeleteAnyMessageGroup; final bool? inChannelCanDeleteOwnMessageGroup; final RealmDeleteOwnMessagePolicy? realmDeleteOwnMessagePolicy; + final UserRole? selfUserRole; CanDeleteMessageParams._({ required this.senderConfig, @@ -2016,6 +2041,7 @@ class CanDeleteMessageParams { required this.inChannelCanDeleteAnyMessageGroup, required this.inChannelCanDeleteOwnMessageGroup, required this.realmDeleteOwnMessagePolicy, + required this.selfUserRole, }); CanDeleteMessageParams.modern({ @@ -2026,7 +2052,9 @@ class CanDeleteMessageParams { required this.isChannelArchived, required this.inChannelCanDeleteAnyMessageGroup, required this.inChannelCanDeleteOwnMessageGroup, - }) : realmDeleteOwnMessagePolicy = null; + }) : + realmDeleteOwnMessagePolicy = null, + selfUserRole = null; factory CanDeleteMessageParams.restrictiveForChannelMessageExcept({ CanDeleteMessageSenderConfig? senderConfig, @@ -2110,6 +2138,7 @@ class CanDeleteMessageParams { inChannelCanDeleteAnyMessageGroup: null, inChannelCanDeleteOwnMessageGroup: null, realmDeleteOwnMessagePolicy: null, + selfUserRole: null, ); // TODO(server-10) delete @@ -2119,6 +2148,7 @@ class CanDeleteMessageParams { required bool inRealmCanDeleteAnyMessageGroup, required bool? isChannelArchived, required RealmDeleteOwnMessagePolicy realmDeleteOwnMessagePolicy, + required UserRole selfUserRole, }) => CanDeleteMessageParams._( senderConfig: senderConfig, timeLimitConfig: timeLimitConfig, @@ -2128,6 +2158,7 @@ class CanDeleteMessageParams { inChannelCanDeleteAnyMessageGroup: null, inChannelCanDeleteOwnMessageGroup: null, realmDeleteOwnMessagePolicy: realmDeleteOwnMessagePolicy, + selfUserRole: selfUserRole, ); // TODO(server-10) delete @@ -2136,6 +2167,7 @@ class CanDeleteMessageParams { required CanDeleteMessageTimeLimitConfig timeLimitConfig, required bool? isChannelArchived, required RealmDeleteOwnMessagePolicy realmDeleteOwnMessagePolicy, + required UserRole selfUserRole, }) => CanDeleteMessageParams._( senderConfig: senderConfig, timeLimitConfig: timeLimitConfig, @@ -2145,6 +2177,7 @@ class CanDeleteMessageParams { inChannelCanDeleteAnyMessageGroup: null, inChannelCanDeleteOwnMessageGroup: null, realmDeleteOwnMessagePolicy: realmDeleteOwnMessagePolicy, + selfUserRole: selfUserRole, ); String describe() { From 35f4dda77c10836e73a1edecd4e4df779f464087 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 16 Sep 2025 11:07:38 -0700 Subject: [PATCH 2/7] message: Fix permissions for admins on pre-291 servers Fixes #1850. --- lib/model/message.dart | 11 ++++++----- test/model/message_test.dart | 27 +++++++++++++++++++++++---- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/lib/model/message.dart b/lib/model/message.dart index 9cc4d76f48..8c8807bdba 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -107,11 +107,12 @@ mixin MessageStore on ChannelStore { return false; } - // TODO(#1850) really the default should be `role:administrators`: - // https://github.com/zulip/zulip-flutter/pull/1842#discussion_r2331362461 - if (realmCanDeleteAnyMessageGroup != null - && selfHasPermissionForGroupSetting(realmCanDeleteAnyMessageGroup!, - GroupSettingType.realm, 'can_delete_any_message_group')) { + if (realmCanDeleteAnyMessageGroup != null) { + if (selfHasPermissionForGroupSetting(realmCanDeleteAnyMessageGroup!, + GroupSettingType.realm, 'can_delete_any_message_group')) { + return true; + } + } else if (selfUser.role.isAtLeast(UserRole.administrator)) { return true; } diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 84441b616f..57bb95d760 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -1188,11 +1188,11 @@ void main() { }); group('pre-281', () { - // The realm-level can-delete-any-message permission - // doesn't exist, so we act as though that's present and denied, - // notably by not throwing. + // The realm-level can-delete-any-message permission doesn't exist, + // so we act as though that's present with role:administrators, + // and we don't throw. - test('denied', () async { + test('self-user is not admin', () async { check(await evaluate( CanDeleteMessageParams.pre281( senderConfig: CanDeleteMessageSenderConfig.otherHuman, @@ -1210,6 +1210,25 @@ void main() { selfUserRole: UserRole.member))) ..isFalse(); }); + + test('self-user is admin', () async { + check(await evaluate( + CanDeleteMessageParams.pre281( + senderConfig: CanDeleteMessageSenderConfig.otherHuman, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + isChannelArchived: false, + realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.everyone, + selfUserRole: UserRole.administrator, + )))..equals(await evaluate( + CanDeleteMessageParams.pre291( + senderConfig: CanDeleteMessageSenderConfig.otherHuman, + timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, + inRealmCanDeleteAnyMessageGroup: true, + isChannelArchived: false, + realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.everyone, + selfUserRole: UserRole.administrator))) + ..isTrue(); + }); }); }); }); From 4495100ddf5396b88e6dd447e3e8141acea65e33 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 16 Sep 2025 11:19:39 -0700 Subject: [PATCH 3/7] message test [nfc]: Reword comment on pre-407 selfCanDeleteMessage tests The "role:nobody" group is the default value for both of these group settings (see defaultGroupName in upcoming commits), so it's helpful to be explicit here. --- test/model/message_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 57bb95d760..4eccf75112 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -1078,8 +1078,8 @@ void main() { group('legacy behavior', () { group('pre-407', () { // The channel-level group permissions don't exist, - // so we act as though they were present and denied, - // notably by not throwing. + // so we act as though they were present with role:nobody, + // and we don't throw. test('denial is not forced just because one of the permissions is absent (the any-message one)', () async { check(await evaluate( From 391b56ae162b46647a5fe56464fd110d12eeeba5 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 18 Sep 2025 15:47:10 -0700 Subject: [PATCH 4/7] model [nfc]: Move has-passed-waiting-period method to RealmStore --- lib/model/channel.dart | 2 +- lib/model/message.dart | 2 +- lib/model/realm.dart | 42 ++++++++++++++++++++++++++++++++++++++ lib/model/user.dart | 22 -------------------- test/model/realm_test.dart | 26 +++++++++++++++++++++++ test/model/user_test.dart | 22 -------------------- 6 files changed, 70 insertions(+), 46 deletions(-) diff --git a/lib/model/channel.dart b/lib/model/channel.dart index e4a40790d4..b29f531bed 100644 --- a/lib/model/channel.dart +++ b/lib/model/channel.dart @@ -213,7 +213,7 @@ mixin ChannelStore on UserStore { case ChannelPostPolicy.fullMembers: { if (!role.isAtLeast(UserRole.member)) return false; if (role == UserRole.member) { - return hasPassedWaitingPeriod(selfUser, byDate: byDate); + return selfHasPassedWaitingPeriod(byDate: byDate); } return true; } diff --git a/lib/model/message.dart b/lib/model/message.dart index 8c8807bdba..12269ce392 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -186,7 +186,7 @@ mixin MessageStore on ChannelStore { case RealmDeleteOwnMessagePolicy.fullMembers: { if (!role.isAtLeast(UserRole.member)) return false; if (role == UserRole.member) { - return hasPassedWaitingPeriod(selfUser, byDate: atDate); + return selfHasPassedWaitingPeriod(byDate: atDate); } return true; } diff --git a/lib/model/realm.dart b/lib/model/realm.dart index 782740405d..5f765136a1 100644 --- a/lib/model/realm.dart +++ b/lib/model/realm.dart @@ -128,6 +128,16 @@ mixin RealmStore on PerAccountStoreBase, UserGroupStore { return topic; } + /// Whether the self-user has passed the realm's waiting period + /// to be a full member. + /// + /// See: + /// https://zulip.com/api/roles-and-permissions#determining-if-a-user-is-a-full-member + /// + /// To determine if the self-user is a full member, + /// callers must also check that the user's role is at least [UserRole.member]. + bool selfHasPassedWaitingPeriod({required DateTime byDate}); + /// Whether the self-user has the given (group-based) permission. bool selfHasPermissionForGroupSetting(GroupSettingValue value, GroupSettingType type, String name); @@ -180,6 +190,9 @@ mixin ProxyRealmStore on RealmStore { @override List get customProfileFields => realmStore.customProfileFields; @override + bool selfHasPassedWaitingPeriod({required DateTime byDate}) => + realmStore.selfHasPassedWaitingPeriod(byDate: byDate); + @override bool selfHasPermissionForGroupSetting(GroupSettingValue value, GroupSettingType type, String name) => realmStore.selfHasPermissionForGroupSetting(value, type, name); } @@ -203,6 +216,7 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { required User selfUser, }) : _selfUserRole = selfUser.role, + _selfUserDateJoined = selfUser.dateJoined, serverPresencePingIntervalSeconds = initialSnapshot.serverPresencePingIntervalSeconds, serverPresenceOfflineThresholdSeconds = initialSnapshot.serverPresenceOfflineThresholdSeconds, serverTypingStartedExpiryPeriodMilliseconds = initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds, @@ -224,6 +238,22 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { realmDefaultExternalAccounts = initialSnapshot.realmDefaultExternalAccounts, customProfileFields = _sortCustomProfileFields(initialSnapshot.customProfileFields); + @override + bool selfHasPassedWaitingPeriod({required DateTime byDate}) { + // [User.dateJoined] is in UTC. For logged-in users, the format is: + // YYYY-MM-DDTHH:mm+00:00, which includes the timezone offset for UTC. + // For logged-out spectators, the format is: YYYY-MM-DD, which doesn't + // include the timezone offset. In the later case, [DateTime.parse] will + // interpret it as the client's local timezone, which could lead to + // incorrect results; but that's acceptable for now because the app + // doesn't support viewing as a spectator. + // + // See the related discussion: + // https://chat.zulip.org/#narrow/channel/412-api-documentation/topic/provide.20an.20explicit.20format.20for.20.60realm_user.2Edate_joined.60/near/1980194 + final dateJoined = DateTime.parse(_selfUserDateJoined); + return byDate.difference(dateJoined).inDays >= realmWaitingPeriodThreshold; + } + @override bool selfHasPermissionForGroupSetting(GroupSettingValue value, GroupSettingType type, String name) { @@ -262,8 +292,20 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { /// The main home of this information is [UserStore]: `store.selfUser.role`. /// We need it here for interpreting some permission settings; /// so we denormalize it here to avoid a cycle between substores. + /// + /// See also [_selfUserDateJoined]. UserRole _selfUserRole; + /// The [User.dateJoined] of the self-user. + /// + /// The main home of this information is [UserStore]: + /// `store.selfUser.dateJoined`. + /// We need it here for interpreting some permission settings; + /// so we denormalize it here to avoid a cycle between substores. + /// + /// See also [_selfUserRole]. + final String _selfUserDateJoined; + @override final int serverPresencePingIntervalSeconds; @override diff --git a/lib/model/user.dart b/lib/model/user.dart index 4983cf48ca..ef26b9e589 100644 --- a/lib/model/user.dart +++ b/lib/model/user.dart @@ -91,28 +91,6 @@ mixin UserStore on PerAccountStoreBase, RealmStore { return getUser(senderId)?.fullName ?? message.senderFullName; } - /// Whether [user] has passed the realm's waiting period to be a full member. - /// - /// See: - /// https://zulip.com/api/roles-and-permissions#determining-if-a-user-is-a-full-member - /// - /// To determine if a user is a full member, callers must also check that the - /// user's role is at least [UserRole.member]. - bool hasPassedWaitingPeriod(User user, {required DateTime byDate}) { - // [User.dateJoined] is in UTC. For logged-in users, the format is: - // YYYY-MM-DDTHH:mm+00:00, which includes the timezone offset for UTC. - // For logged-out spectators, the format is: YYYY-MM-DD, which doesn't - // include the timezone offset. In the later case, [DateTime.parse] will - // interpret it as the client's local timezone, which could lead to - // incorrect results; but that's acceptable for now because the app - // doesn't support viewing as a spectator. - // - // See the related discussion: - // https://chat.zulip.org/#narrow/channel/412-api-documentation/topic/provide.20an.20explicit.20format.20for.20.60realm_user.2Edate_joined.60/near/1980194 - final dateJoined = DateTime.parse(user.dateJoined); - return byDate.difference(dateJoined).inDays >= realmWaitingPeriodThreshold; - } - /// Whether the user with [userId] is muted by the self-user. /// /// Looks for [userId] in a private [Set], diff --git a/test/model/realm_test.dart b/test/model/realm_test.dart index 04ceb28855..3839c068dd 100644 --- a/test/model/realm_test.dart +++ b/test/model/realm_test.dart @@ -35,6 +35,32 @@ void main() { doCheck(eg.t('(no topic)'), eg.t(''), 370); }); + group('selfHasPassedWaitingPeriod', () { + final testCases = [ + ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 0, 10, 00), false), + ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 1, 10, 00), false), + ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 2, 09, 59), false), + ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 2, 10, 00), true), + ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 1000, 07, 00), true), + ]; + + for (final (String dateJoined, DateTime currentDate, bool expected) in testCases) { + test('self-user joined at $dateJoined ${expected ? 'has' : "hasn't"} ' + 'passed waiting period by $currentDate', () { + final selfUser = eg.user(dateJoined: dateJoined); + final store = eg.store( + selfUser: selfUser, + initialSnapshot: eg.initialSnapshot( + realmWaitingPeriodThreshold: 2, + realmUsers: [selfUser], + ), + ); + check(store.selfHasPassedWaitingPeriod(byDate: currentDate)) + .equals(expected); + }); + } + }); + group('selfHasPermissionForGroupSetting', () { // Most of the implementation of this is in [UserGroupStore.selfInGroupSetting], // and is tested in more detail in user_group_test.dart . diff --git a/test/model/user_test.dart b/test/model/user_test.dart index 6d5e5c2519..d306d12f0a 100644 --- a/test/model/user_test.dart +++ b/test/model/user_test.dart @@ -58,28 +58,6 @@ void main() { }); }); - group('hasPassedWaitingPeriod', () { - final store = eg.store(initialSnapshot: - eg.initialSnapshot(realmWaitingPeriodThreshold: 2)); - - final testCases = [ - ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 0, 10, 00), false), - ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 1, 10, 00), false), - ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 2, 09, 59), false), - ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 2, 10, 00), true), - ('2024-11-25T10:00+00:00', DateTime.utc(2024, 11, 25 + 1000, 07, 00), true), - ]; - - for (final (String dateJoined, DateTime currentDate, bool hasPassedWaitingPeriod) in testCases) { - test('user joined at $dateJoined ${hasPassedWaitingPeriod ? 'has' : "hasn't"} ' - 'passed waiting period by $currentDate', () { - final user = eg.user(dateJoined: dateJoined); - check(store.hasPassedWaitingPeriod(user, byDate: currentDate)) - .equals(hasPassedWaitingPeriod); - }); - } - }); - group('RealmUserUpdateEvent', () { // TODO write more tests for handling RealmUserUpdateEvent From b584db5d874dcdbbac85334f3f64f6a2f624baa1 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 18 Sep 2025 15:00:34 -0700 Subject: [PATCH 5/7] permission [nfc]: Move some permissions code out to new file --- lib/api/model/initial_snapshot.dart | 245 ------------------------- lib/api/model/initial_snapshot.g.dart | 35 ---- lib/api/model/permission.dart | 248 ++++++++++++++++++++++++++ lib/api/model/permission.g.dart | 44 +++++ lib/model/realm.dart | 1 + 5 files changed, 293 insertions(+), 280 deletions(-) create mode 100644 lib/api/model/permission.dart create mode 100644 lib/api/model/permission.g.dart diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index a8e99dde28..9250f75fa2 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -424,248 +424,3 @@ class UnreadHuddleSnapshot { Map toJson() => _$UnreadHuddleSnapshotToJson(this); } - -/// Metadata about how to interpret the various group-based permission settings. -/// -/// This is the type that [InitialSnapshot.serverSupportedPermissionSettings] -/// would have, according to the API as it exists as of 2025-08; -/// but that API is documented as unstable and subject to change. -/// -/// For a useful value of this type, see [SupportedPermissionSettings.fixture]. -/// -/// For docs, search for "d_perm" in: https://zulip.com/api/register-queue -@JsonSerializable(fieldRename: FieldRename.snake) -class SupportedPermissionSettings { - final Map realm; - final Map stream; - final Map group; - - /// Metadata about how to interpret certain group-based permission settings, - /// including all those that this client uses, based on "current" servers. - /// - /// "Current" here means as of when this code was written, or last updated; - /// details in comments below. Naturally it'd be better to have an API to - /// get this information from the actual server. - /// - /// Effectively we're counting on it being uncommon for the metadata for a - /// given permission to ever change from one server version to the next, - /// so that the values we take from one server version usually remain valid - /// for all past and future server versions that have the corresponding - /// permission at all. - /// - /// TODO(server): Stabilize [InitialSnapshot.serverSupportedPermissionSettings] - /// or a similar API, and switch to using that. See thread: - /// https://chat.zulip.org/#narrow/channel/378-api-design/topic/server_supported_permission_settings/near/2247549 - static SupportedPermissionSettings fixture = SupportedPermissionSettings( - realm: { - // From the server's Realm.REALM_PERMISSION_GROUP_SETTINGS, - // in zerver/models/realms.py. Current as of 6ab30fcce, 2025-08. - 'create_multiuse_invite_group': PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: false, - // default_group_name=SystemGroups.ADMINISTRATORS, - ), - 'can_access_all_users_group': PermissionSettingsItem( - // require_system_group=True, - // allow_nobody_group=False, - allowEveryoneGroup: true, - // default_group_name=SystemGroups.EVERYONE, - // # Note that user_can_access_all_other_users in the web - // # app is relying on members always have access. - // allowed_system_groups=[SystemGroups.EVERYONE, SystemGroups.MEMBERS], - ), - 'can_add_subscribers_group': PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: false, - // default_group_name=SystemGroups.MEMBERS, - ), - 'can_add_custom_emoji_group': PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: false, - // default_group_name=SystemGroups.MEMBERS, - ), - 'can_create_bots_group': PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: false, - // default_group_name=SystemGroups.MEMBERS, - ), - 'can_create_groups': PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: false, - // default_group_name=SystemGroups.MEMBERS, - ), - 'can_create_public_channel_group': PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: false, - // default_group_name=SystemGroups.MEMBERS, - ), - 'can_create_private_channel_group': PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: false, - // default_group_name=SystemGroups.MEMBERS, - ), - 'can_create_web_public_channel_group': PermissionSettingsItem( - // require_system_group=True, - // allow_nobody_group=True, - allowEveryoneGroup: false, - // default_group_name=SystemGroups.OWNERS, - // allowed_system_groups=[ - // SystemGroups.MODERATORS, - // SystemGroups.ADMINISTRATORS, - // SystemGroups.OWNERS, - // SystemGroups.NOBODY, - // ], - ), - 'can_create_write_only_bots_group': PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: false, - // default_group_name=SystemGroups.MEMBERS, - ), - 'can_delete_any_message_group': PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: false, - // default_group_name=SystemGroups.ADMINISTRATORS, - ), - 'can_delete_own_message_group': PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: true, - // default_group_name=SystemGroups.EVERYONE, - ), - 'can_invite_users_group': PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: false, - // default_group_name=SystemGroups.MEMBERS, - ), - 'can_manage_all_groups': PermissionSettingsItem( - // allow_nobody_group=False, - allowEveryoneGroup: false, - // default_group_name=SystemGroups.OWNERS, - ), - 'can_manage_billing_group': PermissionSettingsItem( - // allow_nobody_group=False, - allowEveryoneGroup: false, - // default_group_name=SystemGroups.ADMINISTRATORS, - ), - 'can_mention_many_users_group': PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: true, - // default_group_name=SystemGroups.ADMINISTRATORS, - ), - 'can_move_messages_between_channels_group': PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: false, - // default_group_name=SystemGroups.MEMBERS, - ), - 'can_move_messages_between_topics_group': PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: true, - // default_group_name=SystemGroups.EVERYONE, - ), - 'can_resolve_topics_group': PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: true, - // default_group_name=SystemGroups.EVERYONE, - ), - 'can_set_delete_message_policy_group': PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: false, - // default_group_name=SystemGroups.MODERATORS, - ), - 'can_set_topics_policy_group': PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: true, - // default_group_name=SystemGroups.MEMBERS, - ), - 'can_summarize_topics_group': PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: true, - // default_group_name=SystemGroups.EVERYONE, - ), - 'direct_message_initiator_group': PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: true, - // default_group_name=SystemGroups.EVERYONE, - ), - 'direct_message_permission_group': PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: true, - // default_group_name=SystemGroups.EVERYONE, - ), - }, - group: {}, // Please go ahead and fill this in when we come to need it. - stream: { - // From the server's Stream.stream_permission_group_settings, - // in zerver/models/streams.py. Current as of f9dc13014, 2025-08. - "can_add_subscribers_group": PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: false, - // default_group_name=SystemGroups.NOBODY, - ), - "can_administer_channel_group": PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: false, - // default_group_name="stream_creator_or_nobody", - ), - "can_delete_any_message_group": PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: true, - // default_group_name=SystemGroups.NOBODY, - ), - "can_delete_own_message_group": PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: true, - // default_group_name=SystemGroups.NOBODY, - ), - "can_move_messages_out_of_channel_group": PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: true, - // default_group_name=SystemGroups.NOBODY, - ), - "can_move_messages_within_channel_group": PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: true, - // default_group_name=SystemGroups.NOBODY, - ), - "can_remove_subscribers_group": PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: true, - // default_group_name=SystemGroups.ADMINISTRATORS, - ), - "can_send_message_group": PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: true, - // default_group_name=SystemGroups.EVERYONE, - ), - "can_subscribe_group": PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: false, - // default_group_name=SystemGroups.NOBODY, - ), - "can_resolve_topics_group": PermissionSettingsItem( - // allow_nobody_group=True, - allowEveryoneGroup: true, - // default_group_name=SystemGroups.NOBODY, - ), - }, - ); - - SupportedPermissionSettings({required this.realm, required this.stream, required this.group}); - - factory SupportedPermissionSettings.fromJson(Map json) => - _$SupportedPermissionSettingsFromJson(json); - - Map toJson() => _$SupportedPermissionSettingsToJson(this); -} - -@JsonSerializable(fieldRename: FieldRename.snake) -class PermissionSettingsItem { - final bool allowEveryoneGroup; - // also other fields not yet used - - PermissionSettingsItem({required this.allowEveryoneGroup}); - - factory PermissionSettingsItem.fromJson(Map json) => - _$PermissionSettingsItemFromJson(json); - - Map toJson() => _$PermissionSettingsItemToJson(this); -} diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 4a4d153e2c..ffcbfba827 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -382,38 +382,3 @@ Map _$UnreadHuddleSnapshotToJson( 'user_ids_string': instance.userIdsString, 'unread_message_ids': instance.unreadMessageIds, }; - -SupportedPermissionSettings _$SupportedPermissionSettingsFromJson( - Map json, -) => SupportedPermissionSettings( - realm: (json['realm'] as Map).map( - (k, e) => - MapEntry(k, PermissionSettingsItem.fromJson(e as Map)), - ), - stream: (json['stream'] as Map).map( - (k, e) => - MapEntry(k, PermissionSettingsItem.fromJson(e as Map)), - ), - group: (json['group'] as Map).map( - (k, e) => - MapEntry(k, PermissionSettingsItem.fromJson(e as Map)), - ), -); - -Map _$SupportedPermissionSettingsToJson( - SupportedPermissionSettings instance, -) => { - 'realm': instance.realm, - 'stream': instance.stream, - 'group': instance.group, -}; - -PermissionSettingsItem _$PermissionSettingsItemFromJson( - Map json, -) => PermissionSettingsItem( - allowEveryoneGroup: json['allow_everyone_group'] as bool, -); - -Map _$PermissionSettingsItemToJson( - PermissionSettingsItem instance, -) => {'allow_everyone_group': instance.allowEveryoneGroup}; diff --git a/lib/api/model/permission.dart b/lib/api/model/permission.dart new file mode 100644 index 0000000000..c0367bd896 --- /dev/null +++ b/lib/api/model/permission.dart @@ -0,0 +1,248 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'permission.g.dart'; + +/// Metadata about how to interpret the various group-based permission settings. +/// +/// This is the type that [InitialSnapshot.serverSupportedPermissionSettings] +/// would have, according to the API as it exists as of 2025-08; +/// but that API is documented as unstable and subject to change. +/// +/// For a useful value of this type, see [SupportedPermissionSettings.fixture]. +/// +/// For docs, search for "d_perm" in: https://zulip.com/api/register-queue +@JsonSerializable(fieldRename: FieldRename.snake) +class SupportedPermissionSettings { + final Map realm; + final Map stream; + final Map group; + + /// Metadata about how to interpret certain group-based permission settings, + /// including all those that this client uses, based on "current" servers. + /// + /// "Current" here means as of when this code was written, or last updated; + /// details in comments below. Naturally it'd be better to have an API to + /// get this information from the actual server. + /// + /// Effectively we're counting on it being uncommon for the metadata for a + /// given permission to ever change from one server version to the next, + /// so that the values we take from one server version usually remain valid + /// for all past and future server versions that have the corresponding + /// permission at all. + /// + /// TODO(server): Stabilize [InitialSnapshot.serverSupportedPermissionSettings] + /// or a similar API, and switch to using that. See thread: + /// https://chat.zulip.org/#narrow/channel/378-api-design/topic/server_supported_permission_settings/near/2247549 + static SupportedPermissionSettings fixture = SupportedPermissionSettings( + realm: { + // From the server's Realm.REALM_PERMISSION_GROUP_SETTINGS, + // in zerver/models/realms.py. Current as of 6ab30fcce, 2025-08. + 'create_multiuse_invite_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.ADMINISTRATORS, + ), + 'can_access_all_users_group': PermissionSettingsItem( + // require_system_group=True, + // allow_nobody_group=False, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.EVERYONE, + // # Note that user_can_access_all_other_users in the web + // # app is relying on members always have access. + // allowed_system_groups=[SystemGroups.EVERYONE, SystemGroups.MEMBERS], + ), + 'can_add_subscribers_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.MEMBERS, + ), + 'can_add_custom_emoji_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.MEMBERS, + ), + 'can_create_bots_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.MEMBERS, + ), + 'can_create_groups': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.MEMBERS, + ), + 'can_create_public_channel_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.MEMBERS, + ), + 'can_create_private_channel_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.MEMBERS, + ), + 'can_create_web_public_channel_group': PermissionSettingsItem( + // require_system_group=True, + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.OWNERS, + // allowed_system_groups=[ + // SystemGroups.MODERATORS, + // SystemGroups.ADMINISTRATORS, + // SystemGroups.OWNERS, + // SystemGroups.NOBODY, + // ], + ), + 'can_create_write_only_bots_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.MEMBERS, + ), + 'can_delete_any_message_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.ADMINISTRATORS, + ), + 'can_delete_own_message_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.EVERYONE, + ), + 'can_invite_users_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.MEMBERS, + ), + 'can_manage_all_groups': PermissionSettingsItem( + // allow_nobody_group=False, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.OWNERS, + ), + 'can_manage_billing_group': PermissionSettingsItem( + // allow_nobody_group=False, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.ADMINISTRATORS, + ), + 'can_mention_many_users_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.ADMINISTRATORS, + ), + 'can_move_messages_between_channels_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.MEMBERS, + ), + 'can_move_messages_between_topics_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.EVERYONE, + ), + 'can_resolve_topics_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.EVERYONE, + ), + 'can_set_delete_message_policy_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.MODERATORS, + ), + 'can_set_topics_policy_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.MEMBERS, + ), + 'can_summarize_topics_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.EVERYONE, + ), + 'direct_message_initiator_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.EVERYONE, + ), + 'direct_message_permission_group': PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.EVERYONE, + ), + }, + group: {}, // Please go ahead and fill this in when we come to need it. + stream: { + // From the server's Stream.stream_permission_group_settings, + // in zerver/models/streams.py. Current as of f9dc13014, 2025-08. + "can_add_subscribers_group": PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.NOBODY, + ), + "can_administer_channel_group": PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name="stream_creator_or_nobody", + ), + "can_delete_any_message_group": PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.NOBODY, + ), + "can_delete_own_message_group": PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.NOBODY, + ), + "can_move_messages_out_of_channel_group": PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.NOBODY, + ), + "can_move_messages_within_channel_group": PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.NOBODY, + ), + "can_remove_subscribers_group": PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.ADMINISTRATORS, + ), + "can_send_message_group": PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.EVERYONE, + ), + "can_subscribe_group": PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: false, + // default_group_name=SystemGroups.NOBODY, + ), + "can_resolve_topics_group": PermissionSettingsItem( + // allow_nobody_group=True, + allowEveryoneGroup: true, + // default_group_name=SystemGroups.NOBODY, + ), + }, + ); + + SupportedPermissionSettings({required this.realm, required this.stream, required this.group}); + + factory SupportedPermissionSettings.fromJson(Map json) => + _$SupportedPermissionSettingsFromJson(json); + + Map toJson() => _$SupportedPermissionSettingsToJson(this); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class PermissionSettingsItem { + final bool allowEveryoneGroup; + // also other fields not yet used + + PermissionSettingsItem({required this.allowEveryoneGroup}); + + factory PermissionSettingsItem.fromJson(Map json) => + _$PermissionSettingsItemFromJson(json); + + Map toJson() => _$PermissionSettingsItemToJson(this); +} diff --git a/lib/api/model/permission.g.dart b/lib/api/model/permission.g.dart new file mode 100644 index 0000000000..9b54450b3a --- /dev/null +++ b/lib/api/model/permission.g.dart @@ -0,0 +1,44 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: constant_identifier_names, unnecessary_cast + +part of 'permission.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SupportedPermissionSettings _$SupportedPermissionSettingsFromJson( + Map json, +) => SupportedPermissionSettings( + realm: (json['realm'] as Map).map( + (k, e) => + MapEntry(k, PermissionSettingsItem.fromJson(e as Map)), + ), + stream: (json['stream'] as Map).map( + (k, e) => + MapEntry(k, PermissionSettingsItem.fromJson(e as Map)), + ), + group: (json['group'] as Map).map( + (k, e) => + MapEntry(k, PermissionSettingsItem.fromJson(e as Map)), + ), +); + +Map _$SupportedPermissionSettingsToJson( + SupportedPermissionSettings instance, +) => { + 'realm': instance.realm, + 'stream': instance.stream, + 'group': instance.group, +}; + +PermissionSettingsItem _$PermissionSettingsItemFromJson( + Map json, +) => PermissionSettingsItem( + allowEveryoneGroup: json['allow_everyone_group'] as bool, +); + +Map _$PermissionSettingsItemToJson( + PermissionSettingsItem instance, +) => {'allow_everyone_group': instance.allowEveryoneGroup}; diff --git a/lib/model/realm.dart b/lib/model/realm.dart index 5f765136a1..40d43a370e 100644 --- a/lib/model/realm.dart +++ b/lib/model/realm.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import '../api/model/events.dart'; import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; +import '../api/model/permission.dart'; import 'store.dart'; import 'user_group.dart'; From 7e765314e9605dedee3ebad36686da1a73ecd9db Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 15 Sep 2025 11:55:02 -0700 Subject: [PATCH 6/7] initial_snapshot [nfc]: Add PermissionSettingsItem.defaultGroupName This doesn't change our parsing of server data because we don't currently look at server_supported_permission_settings in the initial snapshot, as noted in the added TODO. It just adds data to the fixture, modeled on current server code. --- lib/api/model/permission.dart | 176 +++++++++++++++++++++++++------- lib/api/model/permission.g.dart | 23 ++++- 2 files changed, 163 insertions(+), 36 deletions(-) diff --git a/lib/api/model/permission.dart b/lib/api/model/permission.dart index c0367bd896..315f781ea8 100644 --- a/lib/api/model/permission.dart +++ b/lib/api/model/permission.dart @@ -33,6 +33,12 @@ class SupportedPermissionSettings { /// TODO(server): Stabilize [InitialSnapshot.serverSupportedPermissionSettings] /// or a similar API, and switch to using that. See thread: /// https://chat.zulip.org/#narrow/channel/378-api-design/topic/server_supported_permission_settings/near/2247549 + // TODO: When we get this data from the server, it will sometimes be missing + // items that appear here, because they're for newer permissions that the + // server doesn't know about. We'll want reasonable fallback behavior for + // those missing items, and as a source for that, we can still record + // current-server data somewhere in our codebase. Discussion: + // https://github.com/zulip/zulip-flutter/pull/1842#discussion_r2331337006 static SupportedPermissionSettings fixture = SupportedPermissionSettings( realm: { // From the server's Realm.REALM_PERMISSION_GROUP_SETTINGS, @@ -40,13 +46,13 @@ class SupportedPermissionSettings { 'create_multiuse_invite_group': PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: false, - // default_group_name=SystemGroups.ADMINISTRATORS, + defaultGroupName: SystemGroupName.administrators, ), 'can_access_all_users_group': PermissionSettingsItem( // require_system_group=True, // allow_nobody_group=False, allowEveryoneGroup: true, - // default_group_name=SystemGroups.EVERYONE, + defaultGroupName: SystemGroupName.everyone, // # Note that user_can_access_all_other_users in the web // # app is relying on members always have access. // allowed_system_groups=[SystemGroups.EVERYONE, SystemGroups.MEMBERS], @@ -54,38 +60,38 @@ class SupportedPermissionSettings { 'can_add_subscribers_group': PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: false, - // default_group_name=SystemGroups.MEMBERS, + defaultGroupName: SystemGroupName.members, ), 'can_add_custom_emoji_group': PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: false, - // default_group_name=SystemGroups.MEMBERS, + defaultGroupName: SystemGroupName.members, ), 'can_create_bots_group': PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: false, - // default_group_name=SystemGroups.MEMBERS, + defaultGroupName: SystemGroupName.members, ), 'can_create_groups': PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: false, - // default_group_name=SystemGroups.MEMBERS, + defaultGroupName: SystemGroupName.members, ), 'can_create_public_channel_group': PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: false, - // default_group_name=SystemGroups.MEMBERS, + defaultGroupName: SystemGroupName.members, ), 'can_create_private_channel_group': PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: false, - // default_group_name=SystemGroups.MEMBERS, + defaultGroupName: SystemGroupName.members, ), 'can_create_web_public_channel_group': PermissionSettingsItem( // require_system_group=True, // allow_nobody_group=True, allowEveryoneGroup: false, - // default_group_name=SystemGroups.OWNERS, + defaultGroupName: SystemGroupName.owners, // allowed_system_groups=[ // SystemGroups.MODERATORS, // SystemGroups.ADMINISTRATORS, @@ -96,77 +102,77 @@ class SupportedPermissionSettings { 'can_create_write_only_bots_group': PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: false, - // default_group_name=SystemGroups.MEMBERS, + defaultGroupName: SystemGroupName.members, ), 'can_delete_any_message_group': PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: false, - // default_group_name=SystemGroups.ADMINISTRATORS, + defaultGroupName: SystemGroupName.administrators, ), 'can_delete_own_message_group': PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: true, - // default_group_name=SystemGroups.EVERYONE, + defaultGroupName: SystemGroupName.everyone, ), 'can_invite_users_group': PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: false, - // default_group_name=SystemGroups.MEMBERS, + defaultGroupName: SystemGroupName.members, ), 'can_manage_all_groups': PermissionSettingsItem( // allow_nobody_group=False, allowEveryoneGroup: false, - // default_group_name=SystemGroups.OWNERS, + defaultGroupName: SystemGroupName.owners, ), 'can_manage_billing_group': PermissionSettingsItem( // allow_nobody_group=False, allowEveryoneGroup: false, - // default_group_name=SystemGroups.ADMINISTRATORS, + defaultGroupName: SystemGroupName.administrators, ), 'can_mention_many_users_group': PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: true, - // default_group_name=SystemGroups.ADMINISTRATORS, + defaultGroupName: SystemGroupName.administrators, ), 'can_move_messages_between_channels_group': PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: false, - // default_group_name=SystemGroups.MEMBERS, + defaultGroupName: SystemGroupName.members, ), 'can_move_messages_between_topics_group': PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: true, - // default_group_name=SystemGroups.EVERYONE, + defaultGroupName: SystemGroupName.everyone, ), 'can_resolve_topics_group': PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: true, - // default_group_name=SystemGroups.EVERYONE, + defaultGroupName: SystemGroupName.everyone, ), 'can_set_delete_message_policy_group': PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: false, - // default_group_name=SystemGroups.MODERATORS, + defaultGroupName: SystemGroupName.moderators, ), 'can_set_topics_policy_group': PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: true, - // default_group_name=SystemGroups.MEMBERS, + defaultGroupName: SystemGroupName.members, ), 'can_summarize_topics_group': PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: true, - // default_group_name=SystemGroups.EVERYONE, + defaultGroupName: SystemGroupName.everyone, ), 'direct_message_initiator_group': PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: true, - // default_group_name=SystemGroups.EVERYONE, + defaultGroupName: SystemGroupName.everyone, ), 'direct_message_permission_group': PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: true, - // default_group_name=SystemGroups.EVERYONE, + defaultGroupName: SystemGroupName.everyone, ), }, group: {}, // Please go ahead and fill this in when we come to need it. @@ -176,52 +182,52 @@ class SupportedPermissionSettings { "can_add_subscribers_group": PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: false, - // default_group_name=SystemGroups.NOBODY, + defaultGroupName: SystemGroupName.nobody, ), "can_administer_channel_group": PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: false, - // default_group_name="stream_creator_or_nobody", + defaultGroupName: PseudoSystemGroupName.streamCreatorOrNobody, ), "can_delete_any_message_group": PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: true, - // default_group_name=SystemGroups.NOBODY, + defaultGroupName: SystemGroupName.nobody, ), "can_delete_own_message_group": PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: true, - // default_group_name=SystemGroups.NOBODY, + defaultGroupName: SystemGroupName.nobody, ), "can_move_messages_out_of_channel_group": PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: true, - // default_group_name=SystemGroups.NOBODY, + defaultGroupName: SystemGroupName.nobody, ), "can_move_messages_within_channel_group": PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: true, - // default_group_name=SystemGroups.NOBODY, + defaultGroupName: SystemGroupName.nobody, ), "can_remove_subscribers_group": PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: true, - // default_group_name=SystemGroups.ADMINISTRATORS, + defaultGroupName: SystemGroupName.administrators, ), "can_send_message_group": PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: true, - // default_group_name=SystemGroups.EVERYONE, + defaultGroupName: SystemGroupName.everyone, ), "can_subscribe_group": PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: false, - // default_group_name=SystemGroups.NOBODY, + defaultGroupName: SystemGroupName.nobody, ), "can_resolve_topics_group": PermissionSettingsItem( // allow_nobody_group=True, allowEveryoneGroup: true, - // default_group_name=SystemGroups.NOBODY, + defaultGroupName: SystemGroupName.nobody, ), }, ); @@ -237,12 +243,112 @@ class SupportedPermissionSettings { @JsonSerializable(fieldRename: FieldRename.snake) class PermissionSettingsItem { final bool allowEveryoneGroup; + + final DefaultGroupName defaultGroupName; + // also other fields not yet used - PermissionSettingsItem({required this.allowEveryoneGroup}); + PermissionSettingsItem({ + required this.allowEveryoneGroup, + required this.defaultGroupName, + }); factory PermissionSettingsItem.fromJson(Map json) => _$PermissionSettingsItemFromJson(json); Map toJson() => _$PermissionSettingsItemToJson(this); } + +/// A value of [PermissionSettingsItem.defaultGroupName]. +/// +/// Can be any of these: +/// - a known system group [SystemGroupName] +/// - a known special string [PseudoSystemGroupName] +/// - an unknown system group or special string [DefaultGroupNameUnknown] +sealed class DefaultGroupName { + DefaultGroupName(); + + factory DefaultGroupName.fromJson(String json) { + final DefaultGroupName? maybeResult = json.startsWith('role:') + ? SystemGroupName.fromJson(json) + : PseudoSystemGroupName.fromJson(json); + return maybeResult ?? DefaultGroupNameUnknown(json); + } + + String toJson(); +} + +class DefaultGroupNameUnknown extends DefaultGroupName { + DefaultGroupNameUnknown(this.apiValue); + final String apiValue; + + @override + String toJson() => apiValue; +} + +/// A known special string +/// that [PermissionSettingsItem.defaultGroupName] might be. +/// +/// See server implementation, e.g. +/// `can_administer_channel_group` in zerver/models/streams.py. +@JsonEnum(valueField: 'apiValue', alwaysCreate: true) +enum PseudoSystemGroupName implements DefaultGroupName { + // Discussion on this; it looks like it might get renamed: + // https://chat.zulip.org/#narrow/channel/378-api-design/topic/stream_creator_or_nobody/near/2258637 + streamCreatorOrNobody(apiValue: 'stream_creator_or_nobody'), + ; + + const PseudoSystemGroupName({required this.apiValue}); + + final String apiValue; + + /// Get a [PseudoSystemGroupName] from an [apiValue], + /// or null if it's not recognized. + /// + /// Example: + /// 'stream_creator_or_nobody' -> PseudoSystemGroupName.streamCreatorOrNobody + static PseudoSystemGroupName? fromJson(String json) => _byApiValue[json]; + + // _$…EnumMap is thanks to `alwaysCreate: true` + static final _byApiValue = _$PseudoSystemGroupNameEnumMap + .map((key, value) => MapEntry(value, key)); + + @override + String toJson() => _$PseudoSystemGroupNameEnumMap[this]!; +} + +/// A known canonical name for a system group. +/// +/// Doc: https://zulip.com/api/group-setting-values#system-groups +@JsonEnum(valueField: 'apiValue', alwaysCreate: true) +enum SystemGroupName implements DefaultGroupName { + // TODO(#1096) audit all references when implementing public-access option + everyoneOnInternet(apiValue: 'role:internet'), + + everyone(apiValue: 'role:everyone'), + members(apiValue: 'role:members'), + fullMembers(apiValue: 'role:fullmembers'), + moderators(apiValue: 'role:moderators'), + administrators(apiValue: 'role:administrators'), + owners(apiValue: 'role:owners'), + nobody(apiValue: 'role:nobody'), + ; + + const SystemGroupName({required this.apiValue}); + + final String apiValue; + + /// Get a [SystemGroupName] from an [apiValue], + /// or null if it's not recognized. + /// + /// Example: + /// 'role:administrators' -> SystemGroupName.administrators + static SystemGroupName? fromJson(String json) => _byApiValue[json]; + + // _$…EnumMap is thanks to `alwaysCreate: true` + static final _byApiValue = _$SystemGroupNameEnumMap + .map((key, value) => MapEntry(value, key)); + + @override + String toJson() => _$SystemGroupNameEnumMap[this]!; +} diff --git a/lib/api/model/permission.g.dart b/lib/api/model/permission.g.dart index 9b54450b3a..280822f58f 100644 --- a/lib/api/model/permission.g.dart +++ b/lib/api/model/permission.g.dart @@ -37,8 +37,29 @@ PermissionSettingsItem _$PermissionSettingsItemFromJson( Map json, ) => PermissionSettingsItem( allowEveryoneGroup: json['allow_everyone_group'] as bool, + defaultGroupName: DefaultGroupName.fromJson( + json['default_group_name'] as String, + ), ); Map _$PermissionSettingsItemToJson( PermissionSettingsItem instance, -) => {'allow_everyone_group': instance.allowEveryoneGroup}; +) => { + 'allow_everyone_group': instance.allowEveryoneGroup, + 'default_group_name': instance.defaultGroupName, +}; + +const _$PseudoSystemGroupNameEnumMap = { + PseudoSystemGroupName.streamCreatorOrNobody: 'stream_creator_or_nobody', +}; + +const _$SystemGroupNameEnumMap = { + SystemGroupName.everyoneOnInternet: 'role:internet', + SystemGroupName.everyone: 'role:everyone', + SystemGroupName.members: 'role:members', + SystemGroupName.fullMembers: 'role:fullmembers', + SystemGroupName.moderators: 'role:moderators', + SystemGroupName.administrators: 'role:administrators', + SystemGroupName.owners: 'role:owners', + SystemGroupName.nobody: 'role:nobody', +}; From 3730c909a8f2c4fe4bab52ad2c439fbaf5aa8225 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 11 Sep 2025 15:22:28 -0700 Subject: [PATCH 7/7] initial_snapshot: Centralize (most of) the group-permission defaults Now these are explicit and it's clear where they come from. Thanks Greg for this suggestion: https://github.com/zulip/zulip-flutter/pull/1842#discussion_r2331337006 The exception is the pre-291 fallback, which doesn't fit into a static fixture because it depends on the realm setting realmDeleteOwnMessagePolicy. --- lib/model/channel.dart | 21 +++------ lib/model/message.dart | 23 ++++------ lib/model/realm.dart | 64 ++++++++++++++++++++++++--- test/model/realm_test.dart | 89 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+), 37 deletions(-) diff --git a/lib/model/channel.dart b/lib/model/channel.dart index b29f531bed..0403ba0bdf 100644 --- a/lib/model/channel.dart +++ b/lib/model/channel.dart @@ -174,25 +174,14 @@ mixin ChannelStore on UserStore { bool _selfHasContentAccessViaGroupPermissions(ZulipStream channel) { // Compare web's stream_data.has_content_access_via_group_permissions. - // TODO(#814) try to clean up this logic; perhaps record more explicitly - // what default/fallback value to use for a given group-based permission - // on older servers. - - if (channel.canAddSubscribersGroup != null - && selfHasPermissionForGroupSetting(channel.canAddSubscribersGroup!, - GroupSettingType.stream, 'can_add_subscribers_group')) { - // The behavior before this permission was introduced was equivalent to - // the "nobody" group. - // TODO(server-10): simplify + + if (selfHasPermissionForGroupSetting(channel.canAddSubscribersGroup, + GroupSettingType.stream, 'can_add_subscribers_group')) { return true; } - if (channel.canSubscribeGroup != null - && selfHasPermissionForGroupSetting(channel.canSubscribeGroup!, - GroupSettingType.stream, 'can_subscribe_group')) { - // The behavior before this permission was introduced was equivalent to - // the "nobody" group. - // TODO(server-10): simplify + if (selfHasPermissionForGroupSetting(channel.canSubscribeGroup, + GroupSettingType.stream, 'can_subscribe_group')) { return true; } diff --git a/lib/model/message.dart b/lib/model/message.dart index 12269ce392..68c20c0d65 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -107,19 +107,14 @@ mixin MessageStore on ChannelStore { return false; } - if (realmCanDeleteAnyMessageGroup != null) { - if (selfHasPermissionForGroupSetting(realmCanDeleteAnyMessageGroup!, - GroupSettingType.realm, 'can_delete_any_message_group')) { - return true; - } - } else if (selfUser.role.isAtLeast(UserRole.administrator)) { + if (selfHasPermissionForGroupSetting(realmCanDeleteAnyMessageGroup, + GroupSettingType.realm, 'can_delete_any_message_group')) { return true; } if (channel != null) { - if (channel.canDeleteAnyMessageGroup != null - && selfHasPermissionForGroupSetting(channel.canDeleteAnyMessageGroup!, - GroupSettingType.stream, 'can_delete_any_message_group')) { + if (selfHasPermissionForGroupSetting(channel.canDeleteAnyMessageGroup, + GroupSettingType.stream, 'can_delete_any_message_group')) { return true; } } @@ -138,6 +133,9 @@ mixin MessageStore on ChannelStore { // that's impossible here because `message` can't be an [OutboxMessage] // (it's a [Message] from [MessageStore.messages]). + // (selfHasPermissionForGroupSetting isn't equipped to handle the old-server + // fallback logic for this specific permission; it's dynamic and depends on + // realmDeleteOwnMessagePolicy, so we do our own null check here.) if (realmCanDeleteOwnMessageGroup != null) { if (!selfHasPermissionForGroupSetting(realmCanDeleteOwnMessageGroup!, GroupSettingType.realm, 'can_delete_own_message_group')) { @@ -146,11 +144,8 @@ mixin MessageStore on ChannelStore { return false; } - if ( - channel.canDeleteOwnMessageGroup == null - || !selfHasPermissionForGroupSetting(channel.canDeleteOwnMessageGroup!, - GroupSettingType.stream, 'can_delete_own_message_group') - ) { + if (!selfHasPermissionForGroupSetting(channel.canDeleteOwnMessageGroup, + GroupSettingType.stream, 'can_delete_own_message_group')) { return false; } } diff --git a/lib/model/realm.dart b/lib/model/realm.dart index 40d43a370e..48036a343a 100644 --- a/lib/model/realm.dart +++ b/lib/model/realm.dart @@ -140,8 +140,11 @@ mixin RealmStore on PerAccountStoreBase, UserGroupStore { bool selfHasPassedWaitingPeriod({required DateTime byDate}); /// Whether the self-user has the given (group-based) permission. - bool selfHasPermissionForGroupSetting(GroupSettingValue value, - GroupSettingType type, String name); + /// + /// If the server doesn't know about the permission, + /// pass null for [value] and a reasonable default will be chosen. + bool selfHasPermissionForGroupSetting(GroupSettingValue? value, + GroupSettingType type, String name); } enum GroupSettingType { realm, stream, group } @@ -194,7 +197,7 @@ mixin ProxyRealmStore on RealmStore { bool selfHasPassedWaitingPeriod({required DateTime byDate}) => realmStore.selfHasPassedWaitingPeriod(byDate: byDate); @override - bool selfHasPermissionForGroupSetting(GroupSettingValue value, GroupSettingType type, String name) => + bool selfHasPermissionForGroupSetting(GroupSettingValue? value, GroupSettingType type, String name) => realmStore.selfHasPermissionForGroupSetting(value, type, name); } @@ -256,7 +259,7 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { } @override - bool selfHasPermissionForGroupSetting(GroupSettingValue value, + bool selfHasPermissionForGroupSetting(GroupSettingValue? value, GroupSettingType type, String name) { // Compare web's settings_data.user_has_permission_for_group_setting. // @@ -267,13 +270,60 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { // That exists for deciding whether to offer the "Generate email address" // button, and if so then which users to offer in the dropdown; // it's predicting whether /api/get-stream-email-address would succeed. - if (_selfUserRole == UserRole.guest) { - final config = _groupSettingConfig(type, name); - if (!config.allowEveryoneGroup) return false; + + final config = _groupSettingConfig(type, name); + + if (_selfUserRole == UserRole.guest && !config.allowEveryoneGroup) { + return false; + } + + if (value == null) { + // The server doesn't know about the permission. *We* know about it + // (or presumably we wouldn't have called this method), + // and we know a reasonable default; use that. + return _hasPermissionByDefault(config); } + return selfInGroupSetting(value); } + bool _hasPermissionByDefault(PermissionSettingsItem config) { + switch (config.defaultGroupName) { + case DefaultGroupNameUnknown(): + // When we know about a permission, we should also know about the group + // we've said is the default value for it. + assert(false); + return true; + case PseudoSystemGroupName.streamCreatorOrNobody: + // TODO(#1102) implement + assert(() { + throw UnimplementedError(); + }()); + return true; + case SystemGroupName.everyoneOnInternet: + case SystemGroupName.everyone: + return true; + case SystemGroupName.members: + return _selfUserRole.isAtLeast(UserRole.member); + case SystemGroupName.fullMembers: + // There aren't any permissions where this is the default, and we + // probably won't add any. So for now we skip the complication of + // doing the waiting-period check. + assert(() { + throw UnimplementedError(); + }()); + return _selfUserRole.isAtLeast(UserRole.member); + case SystemGroupName.moderators: + return _selfUserRole.isAtLeast(UserRole.moderator); + case SystemGroupName.administrators: + return _selfUserRole.isAtLeast(UserRole.administrator); + case SystemGroupName.owners: + return _selfUserRole.isAtLeast(UserRole.owner); + case SystemGroupName.nobody: + return false; + } + } + /// The metadata for how to interpret the given group-based permission setting. PermissionSettingsItem _groupSettingConfig(GroupSettingType type, String name) { final supportedSettings = SupportedPermissionSettings.fixture; diff --git a/test/model/realm_test.dart b/test/model/realm_test.dart index 3839c068dd..222a2d1d87 100644 --- a/test/model/realm_test.dart +++ b/test/model/realm_test.dart @@ -2,7 +2,9 @@ import 'package:checks/checks.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/model/permission.dart'; import 'package:zulip/model/realm.dart'; +import 'package:zulip/model/store.dart'; import '../example_data.dart' as eg; @@ -108,6 +110,93 @@ void main() { check(hasPermission(selfUser, group, 'can_send_message_group')) .isFalse(); }); + + group('fallbacks for permissions not known to the server', () { + late PerAccountStore store; + + void prepare({UserRole? selfUserRole}) { + final selfUser = eg.user(role: selfUserRole); + store = eg.store(selfUser: selfUser, + initialSnapshot: eg.initialSnapshot(realmUsers: [selfUser])); + } + + void doCheck(GroupSettingType type, String name, bool expected) { + check(store.selfHasPermissionForGroupSetting(null, type, name)).equals(expected); + } + + for (final pseudoSystemGroupName in PseudoSystemGroupName.values) { + switch (pseudoSystemGroupName) { + case PseudoSystemGroupName.streamCreatorOrNobody: + // TODO implement and test + } + } + + for (final systemGroupName in SystemGroupName.values) { + switch (systemGroupName) { + case SystemGroupName.everyoneOnInternet: + // (No permissions where we use this default value; continue.) + break; + case SystemGroupName.everyone: + test('everyone', () { + prepare(selfUserRole: UserRole.guest); + doCheck(GroupSettingType.realm, 'can_access_all_users_group', true); + }); + case SystemGroupName.members: + test('members, is guest', () { + prepare(selfUserRole: UserRole.guest); + doCheck(GroupSettingType.realm, 'can_add_custom_emoji_group', false); + }); + test('members, is member', () { + prepare(selfUserRole: UserRole.member); + doCheck(GroupSettingType.realm, 'can_add_custom_emoji_group', true); + }); + case SystemGroupName.fullMembers: + // (No permissions where we use this default value; continue.) + break; + case SystemGroupName.moderators: + test('moderators, is member', () { + prepare(selfUserRole: UserRole.member); + doCheck(GroupSettingType.realm, 'can_set_delete_message_policy_group', false); + }); + test('moderators, is moderator', () { + prepare(selfUserRole: UserRole.moderator); + doCheck(GroupSettingType.realm, 'can_set_delete_message_policy_group', true); + }); + case SystemGroupName.administrators: + test('administrators, is moderator', () { + prepare(selfUserRole: UserRole.moderator); + doCheck(GroupSettingType.stream, 'can_remove_subscribers_group', false); + }); + test('administrators, is administrator', () { + prepare(selfUserRole: UserRole.administrator); + doCheck(GroupSettingType.stream, 'can_remove_subscribers_group', true); + }); + case SystemGroupName.owners: + test('owners, is administrator', () { + prepare(selfUserRole: UserRole.administrator); + doCheck(GroupSettingType.realm, 'can_create_web_public_channel_group', false); + }); + test('owners, is owner', () { + prepare(selfUserRole: UserRole.owner); + doCheck(GroupSettingType.realm, 'can_create_web_public_channel_group', true); + }); + case SystemGroupName.nobody: + test('nobody', () { + prepare(selfUserRole: UserRole.owner); + doCheck(GroupSettingType.stream, 'can_delete_own_message_group', false); + }); + } + } + + test('throw on unknown name', () { + // We should know about all the permissions we're trying to implement, + // even the ones old servers don't know about. + prepare(selfUserRole: UserRole.member); + check(() => store.selfHasPermissionForGroupSetting(null, + GroupSettingType.realm, 'example_future_permission_name'), + ).throws(); + }); + }); }); group('customProfileFields', () {