Skip to content

Conversation

@eyeinsky
Copy link
Contributor

@eyeinsky eyeinsky commented Oct 24, 2025

Checklist

  • Add a new entry in an appropriate subdirectory of changelog.d
  • Read and follow the PR guidelines

@zebot zebot added the ok-to-test Approved for running tests in CI, overrides not-ok-to-test if both labels exist label Oct 24, 2025
@eyeinsky eyeinsky force-pushed the ml/WPB-21385-non-searchable-team-members-endpoint branch from cea52ae to 4f322e9 Compare October 27, 2025 14:12
@eyeinsky eyeinsky changed the title WIP /teams/:tid/members searchable fix WPB-21385: Fix /teams/:tid/members for non-searchable/stealth users Oct 27, 2025
@eyeinsky eyeinsky marked this pull request as ready for review October 27, 2025 15:40
@eyeinsky eyeinsky requested a review from a team as a code owner October 27, 2025 15:40
@battermann battermann requested a review from Copilot October 27, 2025 15:57
Copy link
Contributor

Copilot AI left a 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 TeamMemberNotFound error
  • 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
Copy link

Copilot AI Oct 27, 2025

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.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

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.

Copy link
Contributor

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.

Copy link
Contributor

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.

Copy link
Contributor Author

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!

Copy link
Contributor

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
Copy link

Copilot AI Oct 27, 2025

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).

Copilot uses AI. Check for mistakes.
Copy link
Contributor

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...)

Copy link
Contributor Author

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?

Copy link
Contributor

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.

@eyeinsky
Copy link
Contributor Author

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.

Copy link
Contributor

@battermann battermann left a 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)

Comment on lines 441 to 443
assertBool "/teams/:tid/members returns only searchable users to regular team member"
$ Set.fromList foundUids
== Set.fromList searchableUsers'Uids
Copy link
Contributor

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
Copy link
Contributor

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
Copy link
Contributor

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...)

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

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)?

Copy link
Contributor

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.

Copy link
Contributor Author

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
Copy link
Member

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.

Copy link
Member

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.

@eyeinsky
Copy link
Contributor Author

@akshaymankar Ready for another round!

Copy link
Contributor

@battermann battermann left a 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)

@eyeinsky eyeinsky requested review from a team as code owners October 28, 2025 09:40
let f user tm =
if qUnqualified (U.userQualifiedId user) == tm ^. userId && U.userSearchable user
then Just tm
else Nothing -- throw here instead?
Copy link
Contributor

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}
Copy link
Contributor

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?

Copy link
Contributor

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
Copy link
Contributor

@battermann battermann left a 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)
Copy link
Contributor

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?

Copy link
Contributor Author

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?

@eyeinsky eyeinsky merged commit 02ed33e into develop Oct 28, 2025
9 checks passed
@eyeinsky eyeinsky deleted the ml/WPB-21385-non-searchable-team-members-endpoint branch October 28, 2025 13:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ok-to-test Approved for running tests in CI, overrides not-ok-to-test if both labels exist

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants