-
Notifications
You must be signed in to change notification settings - Fork 334
WPB-21385: Fix /teams/:tid/members for non-searchable/stealth users #4826
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
WPB-21385: Fix /teams/:tid/members for non-searchable/stealth users #4826
Conversation
cea52ae to
4f322e9
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This PR modifies the /teams/:tid/members endpoint to filter team members based on their searchability status. Non-admin users will now only see searchable team members, while admins continue to see all team members regardless of searchability.
- Added filtering logic to exclude non-searchable users from team member listings for non-admin users
- Updated API documentation to reflect the new
TeamMemberNotFounderror - Added integration tests to verify filtering behavior for both regular users and admins
Reviewed Changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| services/galley/src/Galley/API/Teams.hs | Implements filtering of non-searchable team members for non-admin users by fetching user data from Brig and checking searchability |
| libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamMember.hs | Adds TeamMemberNotFound error to the API route definition |
| integration/test/Test/Search.hs | Adds test coverage for both regular user (filtered) and admin (unfiltered) access to team members |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| else do | ||
| -- if user isn't an admin, filter by whether team member is searchable | ||
| pwsResults' <- flip filterM (pwsResults pws) $ \tm -> do | ||
| u <- noteS @'TeamMemberNotFound =<< E.getUser (tm ^. userId) -- fetch user from Brig |
Copilot
AI
Oct 27, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This implementation makes an individual Brig API call for each team member during filtering, which could result in an N+1 query pattern. For large teams, this could significantly impact performance. Consider batching the user fetches or implementing a bulk user lookup API to retrieve searchability for all team members in a single request.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sadly this is true, the right way to do it would be to perform a join on the database side, but Cassandra doesn't do joins. Will need to wait for the postgres migration to reach users and team members first.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, but we could get the searchable attributes in a batch instead of each single user.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BrigAPIAccess has GetUsers :: [UserId] -> BrigAPIAccess m [User] which we could use here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yup, the getUser was just a wrapper around GetUsers!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right, but we should get all users of the page at once, not make a request for every single one.
| else do | ||
| -- if user isn't an admin, filter by whether team member is searchable | ||
| pwsResults' <- flip filterM (pwsResults pws) $ \tm -> do | ||
| u <- noteS @'TeamMemberNotFound =<< E.getUser (tm ^. userId) -- fetch user from Brig |
Copilot
AI
Oct 27, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error TeamMemberNotFound is misleading in this context. The team member exists (they're in the team), but their corresponding user record in Brig cannot be found. This error will be confusing for API consumers who see a team member listed but get a TeamMemberNotFound error. Consider using a more specific error like UserNotFound or handling the missing user case differently (e.g., filtering them out silently or logging a warning).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I partly agree. I think we should just skip the users that we cannot find in brig. But we should not return an error. This has nothing to do with the searchable attribute, if the searching user has the SetMemberSearchable permission, we also do not throw this error (because we do not even care if the user exists in brig or not...)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, UserNotFound is a Brig error but we are in Galley. I think we can't rethrow the Brig's error either as the getUser queries an internal endpoint -- should we add a UserNotFound to Galley as well?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just skip the user and do not add them to the search restult.
|
While CI is green and there is an test section for not getting stealth users when a regular team member is searching, then might need to keep an eye on how long do the requests with this "backend filtering" approach take. |
battermann
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we have to change the following:
- get users from brig in a batch to determine the searchable attribute
- do not throw an error if use does not exist, just do not include them in the result
what I don't like is that filtering is applied post-fetch per page, so returned page sizes may be smaller than requested. this may be necessary, but we need to make a note in the docs somewhere. (e.g. it is possible that we get an empty page with has_more=true, which is very surprising to the caller)
integration/test/Test/Search.hs
Outdated
| assertBool "/teams/:tid/members returns only searchable users to regular team member" | ||
| $ Set.fromList foundUids | ||
| == Set.fromList searchableUsers'Uids |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would use shouldMatchSet here. Is there a specific reason to use assertBool?
| else do | ||
| -- if user isn't an admin, filter by whether team member is searchable | ||
| pwsResults' <- flip filterM (pwsResults pws) $ \tm -> do | ||
| u <- noteS @'TeamMemberNotFound =<< E.getUser (tm ^. userId) -- fetch user from Brig |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, but we could get the searchable attributes in a batch instead of each single user.
| else do | ||
| -- if user isn't an admin, filter by whether team member is searchable | ||
| pwsResults' <- flip filterM (pwsResults pws) $ \tm -> do | ||
| u <- noteS @'TeamMemberNotFound =<< E.getUser (tm ^. userId) -- fetch user from Brig |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I partly agree. I think we should just skip the users that we cannot find in brig. But we should not return an error. This has nothing to do with the searchable attribute, if the searching user has the SetMemberSearchable permission, we also do not throw this error (because we do not even care if the user exists in brig or not...)
…archable fetch users in batch
use shouldMatchSet
…archable make sanitize-pr
…archable add comment on why we do this
| uids = map (^. userId) pwsResults0 | ||
| users <- E.getUsers uids | ||
| let searchableUsers'Uids = map (qUnqualified . U.userQualifiedId) $ filter U.userSearchable users | ||
| pwsResults1 = filter (\tm -> (tm ^. userId) `elem` searchableUsers'Uids) pwsResults0 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wouldn’t this end up doing a nested lookup for every item, basically O(n²), since elem scans the whole list each time? Would using Set be a better option (imho)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Even better, you can zip users and pwsResults0, filter once, then extract the results.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@pcapriotti Do you think this is alright: https://github.com/wireapp/wire-server/pull/4826/files#diff-231e4a1faa3508d94466458a77868b8b06786b7a9eec42af70301a975e772939R498-R502 , I compare but then return a Nothing. As I assume these users are supposed to all exist, should I throw instead?
| then do | ||
| pws :: PageWithState TeamMember <- E.listTeamMembers @CassandraPaging tid mState mLimit | ||
| pws' <- | ||
| if member `hasPermission` SetMemberSearchable |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we can make this endpoint filter no matter who is looking, because we confirmed this is only used in the webapp and not team-settings. My understanding is that product doesn't want the stealth users to be found in the webapp at all.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
because we confirmed this is only used in the webapp and not team-settings.
Correction, it's used in all clients (mobile too). But the point stands.
…archable make sanitize-pr
…archable we don't throw this anymore
|
@akshaymankar Ready for another round! |
battermann
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we not need a changelog? (if it is part of another ticket that already has a changelog, we should at least add it there)
| let f user tm = | ||
| if qUnqualified (U.userQualifiedId user) == tm ^. userId && U.userSearchable user | ||
| then Just tm | ||
| else Nothing -- throw here instead? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we remove the comment?
| if qUnqualified (U.userQualifiedId user) == tm ^. userId && U.userSearchable user | ||
| then Just tm | ||
| else Nothing -- throw here instead? | ||
| pure $ toTeamMembersPage member $ pws {pwsResults = catMaybes $ zipWith f users pwsResults0} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is the zipWith robust enough? What if we get the result in the wrong order or if a user is missing from the response?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point. I was assuming getUsers returned exactly the requested users in the same order. If we can't make that assumption, then we need to index them as a map or use a set for filtering like @adamlow-wire suggested.
…archable - use a lookup for users - log not found users as error
battermann
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM, only a nit pick
| let notFoundUserUids = lefts results | ||
| unless (null notFoundUserUids) $ | ||
| P.err $ | ||
| Log.field "targets" (toByteString $ show $ map toByteString notFoundUserUids) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it necessary to do toByteString twice?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't really know, but found such an example elsewhere. Looking deeper, it seems that the field value is freeform text then I guess I could just show it?
Co-authored-by: Leif Battermann <[email protected]>
Checklist
changelog.d