Skip to content

Conversation

@gnprice
Copy link
Member

@gnprice gnprice commented Aug 20, 2025

Fixes #1687.
Fixes #814.

Stacked atop #1814.

This adds all the generic infrastructure needed for implementing any given group-based permission. The last commit (or pair of commits) then adds the permission "has content access (to a channel)" (which we'll want for #188), to demonstrate end-to-end how to add individual permissions using the new infrastructure.

@gnprice gnprice requested a review from chrisbobbe August 20, 2025 00:27
@gnprice gnprice added the maintainer review PR ready for review by Zulip maintainers label Aug 20, 2025
Comment on lines +88 to +91
// TODO(perf), TODO(#814): memoize which groups the self-user is in,
// to save doing this depth-first search on each permission check
return group.members.contains(selfUserId)
|| group.directSubgroupIds.any(_selfInGroup);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a draft branch for this optimization. But it's a bit tricky to get right, so I've separated it out in order to let this PR focus on all the other aspects of the semantics of these permissions.

Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this will be great to have! Comments below.

Comment on lines 105 to 146
void handleRealmUserUpdateEvent(RealmUserUpdateEvent event) {
// TODO(#1687): test handling user deactivation
for (final group in _groups.values) {
group.members.remove(event.userId);
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like it'll touch the groups on any user update, not just deactivation: so when a user changes their name, avatar, etc. 🙂

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oof indeed, good catch, thanks.

return switch (json) {
int() => GroupSettingValueNamed.fromJson(json),
Map<String, dynamic>() => GroupSettingValueNameless.fromJson(json),
_ => throw TypeError(),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

throw FormatException(), maybe? This would be the only result of a git grep 'throw TypeError'

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure

Comment on lines +81 to +91
return group.members.contains(selfUserId)
|| group.directSubgroupIds.any(_selfInGroup);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I hope a group is never a subgroup of itself :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, that's an invariant of the system. The subgroup relation is acyclic: no group is a subgroup of itself, nor of any of its transitive subgroups.

I'm not finding that documented, though — looked here:
https://zulip.com/api/group-setting-values
https://zulip.com/api/update-user-group-subgroups
— so it should probably get written down more explicitly in the API docs.

@JsonKey(readValue: _readUsersIsActiveFallbackTrue)
final List<User> crossRealmBots;

final SupportedPermissionSettings serverSupportedPermissionSettings;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API doc says

New in Zulip 8.0 (feature level 221)

so we should make this optional.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, good catch, thanks.

Comment on lines 644 to 645
GroupSettingValue canAddSubscribersGroup;
GroupSettingValue canSubscribeGroup;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are documented as new in Zulip 10.0, so we should make them optional.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also we should add these fields to ChannelUpdateEvent if they can change with that event, or leave a comment there if they can't.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, thanks for those catches.

return _selfHasContentAccessViaGroupPermissions(channel);
}

bool _selfHasContentAccessViaGroupPermissions(ZulipStream channel) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

channel: Add selfHasContentAccess, using canSubscribeGroup et al

[…]

This particular permission is one we don't yet have any logic that
should consume […]

I think it does actually :) see

which has corresponding TODO(#1786)s in the code.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm indeed, thanks for the tip.

One of those two is directly covered by the permission logic here, so I revised this commit to take care of that. The other requires a bit of a variation, so I think I'll leave #1786 open for handling that one.

@gnprice gnprice force-pushed the pr-groups branch 3 times, most recently from 898844d to d0b8bf5 Compare August 20, 2025 23:27
Copy link
Member Author

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review! Seems I was a bit hasty in sending this yesterday; thanks for those catches. Revision pushed — PTAL.

return switch (json) {
int() => GroupSettingValueNamed.fromJson(json),
Map<String, dynamic>() => GroupSettingValueNameless.fromJson(json),
_ => throw TypeError(),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure

@JsonKey(readValue: _readUsersIsActiveFallbackTrue)
final List<User> crossRealmBots;

final SupportedPermissionSettings serverSupportedPermissionSettings;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, good catch, thanks.

Comment on lines 644 to 645
GroupSettingValue canAddSubscribersGroup;
GroupSettingValue canSubscribeGroup;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, thanks for those catches.

return _selfHasContentAccessViaGroupPermissions(channel);
}

bool _selfHasContentAccessViaGroupPermissions(ZulipStream channel) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm indeed, thanks for the tip.

One of those two is directly covered by the permission logic here, so I revised this commit to take care of that. The other requires a bit of a variation, so I think I'll leave #1786 open for handling that one.

@gnprice
Copy link
Member Author

gnprice commented Aug 20, 2025

I haven't made any revision yet for the point you raised at #api design > server_supported_permission_settings @ 💬: this server_supported_permission_settings API is described as unstable.

@gnprice
Copy link
Member Author

gnprice commented Aug 21, 2025

(I plan to leave this PR out of today's release, pending that API question. I hope to merge a version tomorrow before I go on vacation, though.)

@chrisbobbe
Copy link
Collaborator

chrisbobbe commented Aug 21, 2025

Thanks, this looks great! So I guess hopefully that API design thread resolves soon: #api design > server_supported_permission_settings @ 💬

but as you mentioned there

Pending an answer, I guess our alternative would be to ignore that part of the /api/register-queue response, and instead hard-code whichever values we want to use.

Otherwise LGTM.

@gnprice
Copy link
Member Author

gnprice commented Aug 21, 2025

OK, revised with that change! PTAL.

@gnprice
Copy link
Member Author

gnprice commented Aug 21, 2025

For the record, if we were to decide the existing API is stable and so just start using that, the change to make would be the diff below, plus a couple more items:

  • updating docs on SupportedPermissionSettings and its .fixture
  • optionally, moving SupportedPermissionSettings.fixture into test/example_data.dart (like it was in c475382 , in a previous revision of this PR)
    • The main reason we might not do that is if we decide to have the same structure encode default/fallback values, describing how to behave when a server doesn't have a given permission at all (so presumably is older than the introduction of the permission). In that case we'd still probably rename it, and certainly revise its doc.

That diff's diffstat:

 lib/api/model/initial_snapshot.dart   |   4 ++--
 lib/api/model/initial_snapshot.g.dart | Bin 14829 -> 15167 bytes
 lib/model/realm.dart                  |  16 ++++++++++++++--
 test/example_data.dart                |   3 +++
 4 files changed, 19 insertions(+), 4 deletions(-)

and details:

Diff
diff --git lib/api/model/initial_snapshot.dart lib/api/model/initial_snapshot.dart
index 5edc04afd..3e019cb9a 100644
--- lib/api/model/initial_snapshot.dart
+++ lib/api/model/initial_snapshot.dart
@@ -110,6 +110,5 @@ class InitialSnapshot {
   final List<User> crossRealmBots;
 
-  // TODO(server): Get this API stabilized, to replace [SupportedPermissionSettings.fixture].
-  // final SupportedPermissionSettings? serverSupportedPermissionSettings;
+  final SupportedPermissionSettings? serverSupportedPermissionSettings; // TODO(server-8)
 
   // TODO etc., etc.
@@ -173,4 +172,5 @@ class InitialSnapshot {
     required this.realmNonActiveUsers,
     required this.crossRealmBots,
+    required this.serverSupportedPermissionSettings,
   });
 
diff --git lib/api/model/initial_snapshot.g.dart lib/api/model/initial_snapshot.g.dart
index 481b3184b..05e270a85 100644
Binary files lib/api/model/initial_snapshot.g.dart and lib/api/model/initial_snapshot.g.dart differ
diff --git lib/model/realm.dart lib/model/realm.dart
index 02db21c56..b62973e93 100644
--- lib/model/realm.dart
+++ lib/model/realm.dart
@@ -124,4 +124,6 @@ mixin RealmStore on PerAccountStoreBase, UserGroupStore {
 
   /// Whether the self-user has the given (group-based) permission.
+  ///
+  /// Throws if the server does not have the concept of the given permission.
   bool selfHasPermissionForGroupSetting(GroupSettingValue value,
       GroupSettingType type, String name);
@@ -190,4 +192,5 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore {
   }) :
     _selfUserRole = selfUser.role,
+    _serverSupportedPermissionSettings = initialSnapshot.serverSupportedPermissionSettings,
     serverPresencePingIntervalSeconds = initialSnapshot.serverPresencePingIntervalSeconds,
     serverPresenceOfflineThresholdSeconds = initialSnapshot.serverPresenceOfflineThresholdSeconds,
@@ -226,7 +229,14 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore {
   }
 
-  /// The metadata for how to interpret the given group-based permission setting.
+  /// The config the server supplied for how to interpret the given
+  /// group-based permission setting.
+  ///
+  /// Throws if there is no such config from the server.
+  /// This method should therefore only be called if the server is one that
+  /// actually has the concept of the given permission.
   PermissionSettingsItem _groupSettingConfig(GroupSettingType type, String name) {
-    final supportedSettings = SupportedPermissionSettings.fixture;
+    // If the server has the particular permission being asked about, then it
+    // must have this bit of general infrastructure for group-based permissions.
+    final supportedSettings = _serverSupportedPermissionSettings!;
 
     // Compare web's group_permission_settings.get_group_permission_setting_config.
@@ -247,4 +257,6 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore {
   UserRole _selfUserRole;
 
+  final SupportedPermissionSettings? _serverSupportedPermissionSettings; // TODO(server-8)
+
   @override
   final int serverPresencePingIntervalSeconds;
diff --git test/example_data.dart test/example_data.dart
index 84d1abf6a..d3518ebbb 100644
--- test/example_data.dart
+++ test/example_data.dart
@@ -1251,4 +1251,5 @@ InitialSnapshot initialSnapshot({
   List<User>? realmNonActiveUsers,
   List<User>? crossRealmBots,
+  SupportedPermissionSettings? serverSupportedPermissionSettings,
 }) {
   return InitialSnapshot(
@@ -1300,4 +1301,6 @@ InitialSnapshot initialSnapshot({
     realmNonActiveUsers: realmNonActiveUsers ?? [],
     crossRealmBots: crossRealmBots ?? [],
+    serverSupportedPermissionSettings: serverSupportedPermissionSettings
+      ?? SupportedPermissionSettings.fixture,
   );
 }

Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, LGTM! One nit below, then please merge at will.

Comment on lines 398 to 400
/// 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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
/// 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.
/// 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.

gnprice added 14 commits August 21, 2025 17:32
Fixes zulip#1687.

We'll add tests for this after an upcoming commit provides a way of
accessing group membership that's more relevant for the app's needs.
That way we can write the tests to that interface.
This is the way we'll be inspecting group membership for checking
permissions (zulip#814), which is the main way we'll be using group
membership in general.

Because this method is how most of the app will consume group
membership, adding this method also lets us write natural tests for
the logic that maintains group membership on events.

At this stage we stick with a very straightforward implementation.
We might optimize it later with more data structures.
Fixes zulip#814.
Fixes part of zulip#1786.

The general implementation of group-based permissions (zulip#814)
was added in the commits leading up to this one.  This commit
demonstrates implementing a given such permission.

Then we also use the new permission in one place where we already
want to use it, toward zulip#1786.  (There's another part of zulip#1786 which
calls for a variation: determining whether the user would be able
to re-subscribe to a channel if they unsubscribe.  We'll leave that
for a follow-up.)  We'll also be adding a more prominent bit of UI
that wants this permission soon: the list of channels, zulip#188.

To exercise this method on real data, I added some debugging prints
that printed a list of the channels where this returned true, and a
list of the channels where it returned false.  I also manually tested
the change for zulip#1786, by visiting an unsubscribed private channel
(via the link in a #-mention), long-pressing the app bar to get the
channel action sheet, and then (as another user, on web) adding and
removing permission for the test user to subscribe themself.
@gnprice gnprice merged commit cf83c9e into zulip:main Aug 22, 2025
1 check passed
@gnprice
Copy link
Member Author

gnprice commented Aug 22, 2025

Thanks! Done.

@gnprice gnprice deleted the pr-groups branch August 22, 2025 00:42
chrisbobbe added a commit to chrisbobbe/zulip-flutter that referenced this pull request Oct 1, 2025
gnprice pushed a commit to chrisbobbe/zulip-flutter that referenced this pull request Oct 3, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

maintainer review PR ready for review by Zulip maintainers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Track membership of user groups Implement group-based permission checks

2 participants