Skip to content

Conversation

@eyeinsky
Copy link
Collaborator

@eyeinsky eyeinsky commented Nov 17, 2025

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

  • 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 Nov 17, 2025
@eyeinsky eyeinsky force-pushed the ml/WPB-21769--scim-group-search-users branch from ddc6aca to 4986d67 Compare November 19, 2025 16:56
@eyeinsky eyeinsky force-pushed the ml/WPB-21769--scim-group-search-users branch from 4986d67 to c35f033 Compare November 20, 2025 13:13
@eyeinsky eyeinsky marked this pull request as ready for review November 20, 2025 14:50
@eyeinsky eyeinsky requested review from a team as code owners November 20, 2025 14:50
@battermann battermann requested a review from Copilot November 21, 2025 07:15
Copilot finished reviewing on behalf of battermann November 21, 2025 07:18
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 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 getUserGroupsWithMembers database function that performs a LEFT JOIN to fetch members alongside group metadata
  • Updated the SCIM subsystem to use UserGroupPageWithMembers and 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.

Comment on lines 262 to 269
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_ {..}
Copy link

Copilot AI Nov 21, 2025

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.

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

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

Copilot AI Nov 21, 2025

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.

Suggested change
"count(*) :: int"
"count(gm.user_id) :: int"

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

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

Comment on lines 273 to 275
clause1 "team_id" "=" req.team :
case req.searchString of
Just name -> [ like "name" name ]
Copy link

Copilot AI Nov 21, 2025

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

Suggested change
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 ]

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

@eyeinsky eyeinsky Nov 21, 2025

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.

createdGroup2 <- respGroup2.json
createdGroup3 <- respGroup3.json

-- Test geting a single SCIM group by id
Copy link

Copilot AI Nov 21, 2025

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

Suggested change
-- Test geting a single SCIM group by id
-- Test getting a single SCIM group by id

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

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[]",
Copy link

Copilot AI Nov 21, 2025

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.

Suggested change
"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[]",

Copilot uses AI. Check for mistakes.
Comment on lines 439 to 440
allThreeResp <- filterScimUserGroup OwnDomain tok $ Just "displayName co \"group\""
(allThreeResp.json %. "Resources" & asList) `shouldMatchSet` [createdGroup1, createdGroup2, createdGroup3]
Copy link
Contributor

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.

Comment on lines 443 to 444
justTwo <- filterScimUserGroup OwnDomain tok $ Just "displayName co \"another group\""
(justTwo.json %. "Resources" & asList) `shouldMatchSet` [createdGroup2, createdGroup3]
Copy link
Contributor

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

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

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

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.

Copy link
Collaborator Author

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.

Copy link
Contributor

Choose a reason for hiding this comment

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

ok, I see, thanks!

@eyeinsky
Copy link
Collaborator Author

@battermann I think I've now addressed all of the comments!

Also, Copilot did make some good SQL catches!

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.

5 participants