-
Notifications
You must be signed in to change notification settings - Fork 333
WPB-21769: SCIM group search returning members #4859
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
base: develop
Are you sure you want to change the base?
Conversation
ddc6aca to
4986d67
Compare
4986d67 to
c35f033
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 implements support for returning group members in SCIM group search results. Previously, the /scim/v2/Groups?filter=... endpoint only returned group metadata; now it includes the full member lists in the response.
Key Changes
- Introduced parameterized
UserGroupPage_type to support both metadata-only (UserGroupPage) and full group data with members (UserGroupPageWithMembers) - Added new
getUserGroupsWithMembersdatabase function that performs a LEFT JOIN to fetch members alongside group metadata - Updated the SCIM subsystem to use
UserGroupPageWithMembersand removed the workaround that was converting groups to empty member lists
Reviewed Changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
libs/wire-api/src/Wire/API/UserGroup/Pagination.hs |
Refactored UserGroupPage into parameterized UserGroupPage_ type to support both metadata-only and full group representations |
libs/wire-subsystems/src/Wire/UserGroupStore.hs |
Added GetUserGroupsWithMembers effect to the store interface |
libs/wire-subsystems/src/Wire/UserGroupStore/Postgres.hs |
Implemented getUserGroupsWithMembers with SQL query using LEFT JOIN and GROUP BY to fetch members; refactored common helper functions |
libs/wire-subsystems/src/Wire/UserGroupSubsystem.hs |
Updated GetGroupsInternal effect to return UserGroupPageWithMembers |
libs/wire-subsystems/src/Wire/UserGroupSubsystem/Interpreter.hs |
Changed internal implementation to call getUserGroupsWithMembers |
libs/wire-subsystems/src/Wire/ScimSubsystem/Interpreter.hs |
Removed workaround that was creating empty member lists; now uses full group data directly |
libs/wire-subsystems/src/Wire/BrigAPIAccess.hs |
Updated GetGroupsInternal API to return UserGroupPageWithMembers |
libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs |
Updated RPC implementation to return UserGroupPageWithMembers |
libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs |
Updated internal API route type signature to return UserGroupPageWithMembers |
services/brig/src/Brig/API/Internal.hs |
Updated handler to return UserGroupPageWithMembers |
integration/test/Test/Spar.hs |
Refactored test to verify search functionality; removed unused import; renamed test function |
integration/test/API/Spar.hs |
Moved mkScimGroup and mkScimUser helper functions from test to API module for reusability |
changelog.d/2-features/search-scim-groups-members |
Added changelog entry documenting the feature |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| toUserGroup (Id -> id_, name', managedBy', createdAt', members', Just . fromIntegral -> membersCount) = do | ||
| name <- userGroupNameFromText name' | ||
| managedBy <- parseManagedBy managedBy' | ||
| let createdAt = toUTCTimeMillis createdAt' | ||
| channels = Nothing | ||
| channelsCount = Nothing | ||
| members = Identity (fmap Id members' :: Vector UserId) | ||
| pure $ UserGroup_ {..} |
Copilot
AI
Nov 21, 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.
[nitpick] The membersCount variable is bound in the pattern match but never used in the UserGroup_ construction. The membersCount field is set using the length of the members' vector on line 268, but the SQL query already computes this count. However, using V.length members' is correct and more reliable than using the SQL count, so this is acceptable. Consider removing the unused membersCount binding from the pattern match to avoid confusion.
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, this is a good point. length is O(1) on vectors as well, so it's not recounting the items either (as it would in a list).
| "g.managed_by :: int", | ||
| "g.created_at :: timestamptz", | ||
| "coalesce(array_agg(gm.user_id), array[]::uuid[]) :: uuid[]", | ||
| "count(*) :: int" |
Copilot
AI
Nov 21, 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 SQL query has a semantic issue. It uses count(*) which counts the number of joined rows (including NULL members from the LEFT JOIN), not the actual number of members. This will return incorrect counts:
- For groups with members: count will match the number of members
- For groups without members: count will incorrectly return 1 instead of 0 (due to the LEFT JOIN creating one row with NULL member)
Consider using count(gm.user_id) instead, which only counts non-NULL values and will correctly return 0 for groups without members.
| "count(*) :: int" | |
| "count(gm.user_id) :: int" |
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 is absolutely correct! 2622dc2
| clause1 "team_id" "=" req.team : | ||
| case req.searchString of | ||
| Just name -> [ like "name" name ] |
Copilot
AI
Nov 21, 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 WHERE clause uses unqualified column names (team_id, name) but the query uses table aliases (g, gm). While this may work in PostgreSQL when there's no ambiguity, it's better to qualify these columns with the g. prefix for consistency and clarity (e.g., g.team_id, g.name).
| clause1 "team_id" "=" req.team : | |
| case req.searchString of | |
| Just name -> [ like "name" name ] | |
| clause1 "g.team_id" "=" req.team : | |
| case req.searchString of | |
| Just name -> [ like "g.name" name ] |
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.
If the helpers where these functions are used, were used only on one of the main queries then qualifying would work, but in the other user group query the alias is different, made the alias the same 22a9c88.
integration/test/Test/Spar.hs
Outdated
| createdGroup2 <- respGroup2.json | ||
| createdGroup3 <- respGroup3.json | ||
|
|
||
| -- Test geting a single SCIM group by id |
Copilot
AI
Nov 21, 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.
Typo in comment: "geting" should be "getting".
| -- Test geting a single SCIM group by id | |
| -- Test getting a single SCIM group by id |
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! a1ff47d
| "g.name :: text", | ||
| "g.managed_by :: int", | ||
| "g.created_at :: timestamptz", | ||
| "coalesce(array_agg(gm.user_id), array[]::uuid[]) :: uuid[]", |
Copilot
AI
Nov 21, 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 array_agg(gm.user_id) function will create an array with a single NULL element [NULL] for groups with no members (due to the LEFT JOIN), not an empty array. This will be coalesced to [NULL] instead of [].
Consider filtering out NULLs explicitly: coalesce(array_agg(gm.user_id) FILTER (WHERE gm.user_id IS NOT NULL), array[]::uuid[]) to ensure groups without members get an empty array.
| "coalesce(array_agg(gm.user_id), array[]::uuid[]) :: uuid[]", | |
| "coalesce(array_agg(gm.user_id) FILTER (WHERE gm.user_id IS NOT NULL), array[]::uuid[]) :: uuid[]", |
integration/test/Test/Spar.hs
Outdated
| allThreeResp <- filterScimUserGroup OwnDomain tok $ Just "displayName co \"group\"" | ||
| (allThreeResp.json %. "Resources" & asList) `shouldMatchSet` [createdGroup1, createdGroup2, createdGroup3] |
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.
It's better to use bindResponse which show better logs in case of failure.
integration/test/Test/Spar.hs
Outdated
| justTwo <- filterScimUserGroup OwnDomain tok $ Just "displayName co \"another group\"" | ||
| (justTwo.json %. "Resources" & asList) `shouldMatchSet` [createdGroup2, createdGroup3] |
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.
Better use bindResponse, see above
| getUserGroupsWithMembers req = runTransaction TxSessions.ReadCommitted TxSessions.Read $ do | ||
| groups :: [UserGroup] <- Tx.statement () $ refineResult (mapM toUserGroup) $ buildStatement query rows | ||
| count <- getUserGroupCount req | ||
| return $ UserGroupPage groups count |
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 convention is to use pure. I wouldn't care so much if my linter would mark this red... :)
| ], | ||
| "from user_group g", | ||
| "left join user_group_member gm on g.id = gm.user_group_id" | ||
| ] <> [where_ (groupMatchIdName req <> groupPaginationWhereClause req)] <> map literal [ |
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.
No need to map over a singleton list.
| "from user_group g", | ||
| "left join user_group_member gm on g.id = gm.user_group_id" | ||
| ] <> [where_ (groupMatchIdName req <> groupPaginationWhereClause req)] <> map literal [ | ||
| "group by g.team_id, g.id" |
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 query selects more columns than in the group by. Not sure how this can work.
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.
It is because the fields named in the group by parts determine the other non-aggregated columns (name, managed_by, ..), and the rest (the user IDs) are aggregated.
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.
ok, I see, thanks!
don't map over singleton list
Use `bindResponse`
add test case for empty group
make sanitize-pr
make sanitize-pr
|
@battermann I think I've now addressed all of the comments! Also, Copilot did make some good SQL catches! |
WPB-21769: scim group search should return member lists, not just the metadata.
Return a list of members from Spar's
/scim/v2/Groups?filter=...with group members included in the search result.Checklist
changelog.d